openapi: 3.0.3
info:
  title: Dashing Crypto Prepaid Transaction Fee API
  version: 1.0.0
  description: |
    ## How it works
    1. **Read the account.** `GET /{address}` for the address you hold the key
       for: its credit, and how to top it up — the treasury address, the
       minimum for this address now, and whether we will pay the network for
       the deposit and for how much.
    2. **Deposit.** Either send the USDT to the treasury yourself and give us
       the transaction id, or, where the account says we sponsor deposits, sign
       the transfer and hand it over: we pay the network for it, broadcast it,
       and credit the amount less the sponsored fee, which may be zero.
    3. **Ask the price.** Where from, where to, how much. The answer is what the
       send will take from the credit.
    4. **Send.** Your user signs the USDT transfer with their own key. You hand
       it over in a request signed by the account address, which is what lets
       the credit be charged. We put energy and bandwidth on the sending
       address, broadcast, and follow the transaction to a block. One
       transaction on-chain, nothing else.
    5. **Read the result.** The credit, and the list of what was charged and
       why.

    Credit is **not refundable** and does not expire. What happens to the
    sending address between the quote and the block is yours. A send that fails
    on our side before any energy reaches the sending address is credited back;
    once energy is on the address, the price stands however the send ends.

    ## The account
    There is no registration and no API key. An account is a TRON address you
    hold the key for, and every path starts with it: `/{address}`. Every
    request is signed by that address, and a read may carry a read token
    instead. Nothing about an account is shown to anyone without its key or a
    token the key made. An address that has never deposited is an account
    with a credit of `0.00`. The deposit terms that do not depend on the
    address are open to anyone at `GET /terms`.

    ## Read tokens
    A wallet that keeps its key behind a fingerprint should not ask for one
    to show a balance. Sign `POST /{address}/tokens` once and you get a read
    token, `prt_…`. Send it as `Authorization: Bearer prt_…` on any `GET`
    under that address (the account, the history, one transaction), with no
    `issued` and no `signature`.

    A token reads and nothing else. Quotes, deposits and sends are signed by
    the key, and a token never makes another token. It reads only the address
    and the network it was made for.

    A token stays good until it goes 180 days unused, and every read starts
    the 180 days again. One that has lapsed or was revoked answers `401` with
    `TOKEN_EXPIRED`: sign `POST /{address}/tokens` for a new one. We keep only
    its hash, so it is shown once; keep it as you would a session. An address
    holds up to ten, and an eleventh drops the one read least recently.
    `DELETE /{address}/tokens` with a token ends that token; signed by the
    key, it ends every one, for a phone that was lost.

    ## Signing
    One rule for every signed request, whatever its method. Two query
    parameters travel on every one, a POST included; there are no custom
    headers and nothing about the signature is in the body. A read that
    carries a read token needs neither, and neither does `GET /terms`.

    - **issued**: epoch seconds when the request was signed; it must be within
      five minutes of our clock either way, which leaves room for a clock that
      drifts. Every response carries the standard `Date` header, our clock,
      for a client that wants to correct its own.
    - **signature**: 65 bytes `r‖s‖v` as hex.

    `issued` is what makes a signed request expire: a URL that leaks into a log
    is useless five minutes later. Within those five minutes a request can be
    sent again, and that is safe here: every write is idempotent on its
    transaction, a quote charges nothing, and a repeated read shows what the
    first one did.

    The signed message is five lines joined by `\n`, with no trailing newline:

    ```text
    Dashing Crypto prepaid v1
    <METHOD>
    <path>
    <query>
    <body hash>
    ```

    - **METHOD**: upper case, `GET`, `POST` or `DELETE`.
    - **path**: the path exactly as sent, from the first `/`, including the
      network prefix: `/prepaid/TXYZ…/transfers` on mainnet,
      `/nile/prepaid/TXYZ…/transfers` on Nile. A signature for one network is
      refused on the other.
    - **query**: every query parameter except `signature`, `issued`
      included, each name and value percent-encoded as RFC 3986
      unreserved characters (`A–Z a–z 0–9 - . _ ~` stay as they are,
      everything else is `%XX` in upper-case hex), sorted by the encoded
      name and then the encoded value, written `name=value` and joined by
      `&`. Send the query in that same form, with `&signature=…` added.
    - **body hash**: SHA-256 of the body exactly as sent, as lower-case hex.
      A request with no body hashes the empty string:
      `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`.

    Wrap the message as a TRON signed message (TIP-191: the bytes of
    `\x19TRON Signed Message:\n`, the message's length in bytes as a decimal
    string, then the message), hash it with keccak-256, and sign the hash with
    secp256k1. `v` is 27 or 28. We recover the signer and require it to equal
    `{address}` in the path.

    Send the body you hashed, byte for byte: a client that re-serialises its
    JSON after signing will be refused.

    Every operation below has a cURL request and a complete program in
    Node.js, Python and Java beside it, signing included, and the two that
    hand over a transaction build and sign that too. curl cannot sign, so
    the cURL requests that need a signature get it from
    [sign.mjs](/developers/prepaid/clients/sign.mjs), which prints the
    signed query. The same code as one client per language:
    [prepaid.mjs](/developers/prepaid/clients/prepaid.mjs) (TronWeb),
    [prepaid.py](/developers/prepaid/clients/prepaid.py) and
    [PrepaidClient.java](/developers/prepaid/clients/PrepaidClient.java)
    (web3j).

    ## Conventions
    TRON only, USDT only (`TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`). `{address}`
    is always the account; `from` and `to` are the send's own. Amounts are
    decimal strings in USDT with up to six places. **Every figure in a
    response is as of that response**: a credit, a fee, a price or a minimum
    is what it was when we answered, and there is no expiry time to compare
    clocks against. The only times we return are when something happened, in
    the history, as ISO-8601 UTC.
    Lists are newest first and paginated by an opaque `cursor`. Rate limits are
    per account with a looser one per IP; a `429` says in its body, as
    `retryAfter`, how many seconds to wait, and in the `Retry-After` header too. No
    version segment in the path: a breaking change gets a new prefix.

servers:
  - url: https://api.crypto.dashing.ws/prepaid
    description: Mainnet
  - url: https://api.crypto.dashing.ws/nile/prepaid
    description: Nile testnet; lower minimum deposit

tags:
  - name: Account
  - name: Read tokens
  - name: Deposits
  - name: Sends
  - name: History

paths:
  /terms:
    get:
      tags: [Account]
      summary: The deposit terms, with no key
      operationId: getTerms
      # x-codeSamples: generated by scripts/prepaid-samples.mjs from scripts/prepaid-samples/; edit there
      x-codeSamples:
        - lang: shell
          label: cURL
          source: |
            # GET /terms: the deposit terms, with no key
            # Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid
            # No key and no token: the terms are open to anyone.
            curl -s 'https://api.crypto.dashing.ws/prepaid/terms'
            # {"currency":"USDT","deposit":{"treasury":"T…","minimum":"20.00","sponsored":{"available":true,"fee":"0.00"}}}
        - lang: javascript
          label: Node.js
          source: |
            // GET /terms: the deposit terms, with no key
            // Node 18 or later. No key and no token: the terms are open to anyone.

            // Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid
            const API = 'https://api.crypto.dashing.ws/prepaid';

            const response = await fetch(`${API}/terms`);
            const terms = await response.json();
            if (!response.ok) throw new Error(`${response.status} ${terms.code}: ${terms.message}`);
            console.log(terms.deposit);
            // { treasury: 'T…', minimum: '20.00', sponsored: { available: true, fee: '0.00' } }
        - lang: python
          label: Python
          source: |
            # GET /terms: the deposit terms, with no key
            # Python 3.9 or later:  pip install requests
            # No key and no token: the terms are open to anyone.
            import requests

            # Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid
            API = "https://api.crypto.dashing.ws/prepaid"

            response = requests.get(f"{API}/terms", timeout=30)
            terms = response.json()
            if not response.ok:
                raise RuntimeError(f"{response.status_code} {terms['code']}: {terms['message']}")
            print(terms["deposit"])
            # {'treasury': 'T...', 'minimum': '20.00', 'sponsored': {'available': True, 'fee': '0.00'}}
        - lang: java
          label: Java
          source: |
            // GET /terms: the deposit terms, with no key
            // Java 17 or later, with com.fasterxml.jackson.core:jackson-databind
            // No key and no token: the terms are open to anyone.
            import com.fasterxml.jackson.databind.JsonNode;
            import com.fasterxml.jackson.databind.ObjectMapper;
            import java.net.URI;
            import java.net.http.HttpClient;
            import java.net.http.HttpRequest;
            import java.net.http.HttpResponse;

            public class Prepaid {

                // Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid
                static final String API = "https://api.crypto.dashing.ws/prepaid";

                public static void main(String[] args) throws Exception {
                    HttpResponse<String> response = HttpClient.newHttpClient().send(
                            HttpRequest.newBuilder(URI.create(API + "/terms")).GET().build(),
                            HttpResponse.BodyHandlers.ofString());
                    JsonNode terms = new ObjectMapper().readTree(response.body());
                    if (response.statusCode() >= 400) {
                        throw new IllegalStateException(response.statusCode() + " " + terms.path("code").asText()
                                + ": " + terms.path("message").asText());
                    }
                    System.out.println(terms.path("deposit"));
                    // {"treasury":"T…","minimum":"20.00","sponsored":{"available":true,"fee":"0.00"}}
                }
            }
      # x-codeSamples: end
      description: |
        The terms of a deposit for an address with none of its own: where to
        send, the least one deposit may be, and whether we will pay the network
        for it and what we take for that.

        No signature and no token, for a client that has not yet asked the key
        for either. The account read gives the terms for one address, which
        can differ; read it before a deposit when you can.
      security: []
      responses:
        '200':
          description: The terms
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Terms' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /{address}:
    get:
      tags: [Account]
      summary: The credit, and how to top it up
      operationId: getAccount
      # x-codeSamples: generated by scripts/prepaid-samples.mjs from scripts/prepaid-samples/; edit there
      x-codeSamples:
        - lang: shell
          label: cURL
          source: |
            # GET /{address}: the credit, and how to top it up
            # Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid
            # ACCOUNT is the address of the account, READ_TOKEN a token from POST /{address}/tokens.
            curl -s "https://api.crypto.dashing.ws/prepaid/$ACCOUNT" -H "Authorization: Bearer $READ_TOKEN"
            # {"address":"T…","currency":"USDT","credit":"10.00","deposit":{"treasury":"T…","minimum":"20.00",…}}

            # Or signed by the key of the account instead of a token.
            # curl cannot sign. sign.mjs signs with ACCOUNT_KEY (hex) and prints the query to send:
            #   curl -sO https://crypto.dashing.ws/developers/prepaid/clients/sign.mjs && npm install tronweb@6
            QUERY=$(node sign.mjs GET "/prepaid/$ACCOUNT")
            curl -s "https://api.crypto.dashing.ws/prepaid/$ACCOUNT?$QUERY"
        - lang: javascript
          label: Node.js
          source: |
            // GET /{address}: the credit, and how to top it up
            // Node 18 or later:  npm install tronweb@6
            import { createHash } from 'node:crypto';
            import { TronWeb } from 'tronweb';

            // Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
            const API = 'https://api.crypto.dashing.ws/prepaid';
            const NODE = 'https://api.trongrid.io';
            const ACCOUNT_KEY = process.env.ACCOUNT_KEY; // hex; the account's key signs every request
            const ACCOUNT = TronWeb.address.fromPrivateKey(ACCOUNT_KEY);
            const tronWeb = new TronWeb({ fullHost: NODE });

            // Every request is signed by the account's key. The signed message is five lines:
            //   Dashing Crypto prepaid v1
            //   METHOD
            //   the path as sent, network prefix included     /prepaid/T…/transfers
            //   the query without signature, encoded, sorted  issued=1790467200&limit=25
            //   SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
            // signed as a TRON message (TIP-191), which is what signMessageV2 does.
            async function call(method, path, { query = {}, body } = {}) {
              const url = new URL(`${API}/${ACCOUNT}${path}`);
              const encode = (s) =>
                encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
              const canonical = Object.entries({ ...query, issued: Math.floor(Date.now() / 1000) })
                .map(([name, value]) => [encode(name), encode(String(value))])
                .sort(([a, x], [b, y]) => (a === b ? (x < y ? -1 : 1) : a < b ? -1 : 1))
                .map(([name, value]) => `${name}=${value}`)
                .join('&');
              const text = body === undefined ? '' : JSON.stringify(body);
              const message = [
                'Dashing Crypto prepaid v1',
                method,
                url.pathname,
                canonical,
                createHash('sha256').update(text, 'utf8').digest('hex'),
              ].join('\n');
              const signature = (await tronWeb.trx.signMessageV2(message, ACCOUNT_KEY)).slice(2); // r‖s‖v, hex

              const response = await fetch(`${url}?${canonical}&signature=${signature}`, {
                method,
                headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
                body: body === undefined ? undefined : text, // the bytes that were hashed, unchanged
              });
              if (response.status === 204) return null; // a DELETE has no body
              const json = await response.json();
              if (!response.ok) throw new Error(`${response.status} ${json.code}: ${json.message}`);
              return json;
            }

            // Signed by the account's key. A wallet that keeps its key behind a fingerprint reads with a read
            // token instead: see POST /{address}/tokens.
            const account = await call('GET', '');
            console.log(account.credit, account.deposit);
            // 10.00 { treasury: 'T…', minimum: '20.00', sponsored: { available: true, fee: '0.00' } }
        - lang: python
          label: Python
          source: |
            # GET /{address}: the credit, and how to top it up
            # Python 3.9 or later:  pip install coincurve pycryptodome base58 requests
            import hashlib
            import json
            import os
            import time
            from urllib.parse import quote, urlsplit

            import base58
            import requests
            from Crypto.Hash import keccak
            from coincurve import PrivateKey

            # Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
            API = "https://api.crypto.dashing.ws/prepaid"
            NODE = "https://api.trongrid.io"
            ACCOUNT_KEY = os.environ["ACCOUNT_KEY"]  # hex; the account's key signs every request


            def keccak256(data: bytes) -> bytes:
                return keccak.new(digest_bits=256, data=data).digest()


            def address_of(private_key_hex: str) -> str:
                """0x41 and the last 20 bytes of keccak(public key), in base58check."""
                public = PrivateKey(bytes.fromhex(private_key_hex)).public_key.format(compressed=False)[1:]
                return base58.b58encode_check(b"\x41" + keccak256(public)[-20:]).decode()


            ACCOUNT = address_of(ACCOUNT_KEY)


            def call(method: str, path: str, query: dict = None, body: dict = None) -> dict:
                """One request, signed by the account's key.

                The signed message is five lines:
                  Dashing Crypto prepaid v1
                  METHOD
                  the path as sent, network prefix included     /prepaid/T.../transfers
                  the query without signature, encoded, sorted  issued=1790467200&limit=25
                  SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
                signed as a TRON message (TIP-191): keccak-256 over "\\x19TRON Signed Message:\\n",
                the message's length in bytes and the message; secp256k1; r || s || v, v 27 or 28.
                """
                full_path = f"{urlsplit(API).path}/{ACCOUNT}{path}"
                params = dict(query or {}, issued=int(time.time()))
                encode = lambda value: quote(str(value), safe="-._~")  # RFC 3986
                canonical = "&".join(f"{k}={v}" for k, v in sorted((encode(k), encode(v)) for k, v in params.items()))
                data = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
                message = "\n".join([
                    "Dashing Crypto prepaid v1",
                    method,
                    full_path,
                    canonical,
                    hashlib.sha256(data).hexdigest(),
                ]).encode()

                digest = keccak256(b"\x19TRON Signed Message:\n" + str(len(message)).encode() + message)
                raw = PrivateKey(bytes.fromhex(ACCOUNT_KEY)).sign_recoverable(digest, hasher=None)
                signature = (raw[:64] + bytes([raw[64] + 27])).hex()

                origin = "{0.scheme}://{0.netloc}".format(urlsplit(API))
                response = requests.request(
                    method,
                    f"{origin}{full_path}?{canonical}&signature={signature}",
                    data=data if body is not None else None,  # the bytes that were hashed, unchanged
                    headers={"Content-Type": "application/json"} if body is not None else {},
                    timeout=30,
                )
                if response.status_code == 204:  # a DELETE has no body
                    return None
                result = response.json()
                if not response.ok:
                    raise RuntimeError(f"{response.status_code} {result['code']}: {result['message']}")
                return result


            # Signed by the account's key. A wallet that keeps its key behind a fingerprint reads with a read
            # token instead: see POST /{address}/tokens.
            account = call("GET", "")
            print(account["credit"], account["deposit"])
            # 10.00 {'treasury': 'T...', 'minimum': '20.00', 'sponsored': {'available': True, 'fee': '0.00'}}
        - lang: java
          label: Java
          source: |
            // GET /{address}: the credit, and how to top it up
            // Java 17 or later, with org.web3j:crypto:5.0.0 and com.fasterxml.jackson.core:jackson-databind
            import com.fasterxml.jackson.databind.JsonNode;
            import com.fasterxml.jackson.databind.ObjectMapper;
            import java.io.ByteArrayOutputStream;
            import java.math.BigDecimal;
            import java.math.BigInteger;
            import java.net.URI;
            import java.net.http.HttpClient;
            import java.net.http.HttpRequest;
            import java.net.http.HttpResponse;
            import java.nio.charset.StandardCharsets;
            import java.util.Arrays;
            import java.util.Comparator;
            import java.util.Map;
            import java.util.stream.Collectors;
            import org.web3j.crypto.ECKeyPair;
            import org.web3j.crypto.Hash;
            import org.web3j.crypto.Keys;
            import org.web3j.crypto.Sign;
            import org.web3j.utils.Numeric;

            public class Prepaid {

                // Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
                static final String API = "https://api.crypto.dashing.ws/prepaid";
                static final String NODE = "https://api.trongrid.io";
                static final ECKeyPair ACCOUNT_KEY = key(System.getenv("ACCOUNT_KEY")); // signs every request
                static final String ACCOUNT = addressOf(ACCOUNT_KEY);
                static final ObjectMapper JSON = new ObjectMapper();
                static final HttpClient HTTP = HttpClient.newHttpClient();

                public static void main(String[] args) throws Exception {
            // Signed by the account's key. A wallet that keeps its key behind a fingerprint reads with a read
            // token instead: see POST /{address}/tokens.
            JsonNode account = call("GET", "", Map.of(), null);
            System.out.println(account.path("credit").asText() + " " + account.path("deposit"));
            // 10.00 {"treasury":"T…","minimum":"20.00","sponsored":{"available":true,"fee":"0.00"}}
                }

                /**
                 * One request, signed by the account's key. The signed message is five lines:
                 *
                 * <pre>
                 *   Dashing Crypto prepaid v1
                 *   METHOD
                 *   the path as sent, network prefix included     /prepaid/T…/transfers
                 *   the query without signature, encoded, sorted  issued=1790467200&amp;limit=25
                 *   SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
                 * </pre>
                 *
                 * signed as a TRON message (TIP-191): keccak-256 over "\x19TRON Signed Message:\n", the
                 * message's length in bytes and the message; secp256k1; r‖s‖v with v 27 or 28.
                 */
                static JsonNode call(String method, String path, Map<String, String> query, Object body) throws Exception {
                    URI api = URI.create(API);
                    String fullPath = api.getPath() + "/" + ACCOUNT + path;

                    Map<String, String> params = new java.util.HashMap<>(query);
                    params.put("issued", Long.toString(System.currentTimeMillis() / 1000));
                    String canonical = params.entrySet().stream()
                            .map(e -> new String[] {encode(e.getKey()), encode(e.getValue())})
                            .sorted(Comparator.<String[], String>comparing(p -> p[0]).thenComparing(p -> p[1]))
                            .map(p -> p[0] + "=" + p[1])
                            .collect(Collectors.joining("&"));

                    byte[] bytes = body == null ? new byte[0] : JSON.writeValueAsBytes(body);
                    byte[] message = String.join("\n",
                            "Dashing Crypto prepaid v1",
                            method,
                            fullPath,
                            canonical,
                            Numeric.toHexStringNoPrefix(Hash.sha256(bytes))).getBytes(StandardCharsets.UTF_8);
                    byte[] digest = Hash.sha3(concat( // keccak-256
                            ("\u0019TRON Signed Message:\n" + message.length).getBytes(StandardCharsets.UTF_8), message));
                    Sign.SignatureData sig = Sign.signMessage(digest, ACCOUNT_KEY, false); // v is 27 or 28
                    String signature = Numeric.toHexStringNoPrefix(concat(sig.getR(), sig.getS(), sig.getV()));

                    HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(
                            api.getScheme() + "://" + api.getAuthority() + fullPath + "?" + canonical + "&signature=" + signature));
                    if (body == null) {
                        request.method(method, HttpRequest.BodyPublishers.noBody());
                    } else { // the bytes that were hashed, unchanged
                        request.header("Content-Type", "application/json").method(method, HttpRequest.BodyPublishers.ofByteArray(bytes));
                    }
                    HttpResponse<String> response = HTTP.send(request.build(), HttpResponse.BodyHandlers.ofString());
                    if (response.statusCode() == 204) {
                        return null; // a DELETE has no body
                    }
                    JsonNode json = JSON.readTree(response.body());
                    if (response.statusCode() >= 400) {
                        throw new IllegalStateException(
                                response.statusCode() + " " + json.path("code").asText() + ": " + json.path("message").asText());
                    }
                    return json;
                }

                /** RFC 3986: A–Z a–z 0–9 - . _ ~ stay; every other byte is %XX in upper-case hex. */
                static String encode(String value) {
                    StringBuilder out = new StringBuilder();
                    for (byte b : value.getBytes(StandardCharsets.UTF_8)) {
                        int c = b & 0xFF;
                        boolean unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
                                || c == '-' || c == '.' || c == '_' || c == '~';
                        out.append(unreserved ? String.valueOf((char) c) : String.format("%%%02X", c));
                    }
                    return out.toString();
                }

                static final String BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";

                static ECKeyPair key(String hex) {
                    return ECKeyPair.create(new BigInteger(hex, 16));
                }

                /** 0x41 and the last 20 bytes of keccak(public key), in base58check. */
                static String addressOf(ECKeyPair key) {
                    byte[] body = concat(new byte[] {0x41}, Numeric.hexStringToByteArray(Keys.getAddress(key.getPublicKey())));
                    byte[] bytes = concat(body, Arrays.copyOf(Hash.sha256(Hash.sha256(body)), 4));
                    StringBuilder out = new StringBuilder();
                    for (BigInteger v = new BigInteger(1, bytes); v.signum() > 0; v = v.divide(BigInteger.valueOf(58))) {
                        out.append(BASE58.charAt(v.mod(BigInteger.valueOf(58)).intValue()));
                    }
                    return out.reverse().toString();
                }

                static byte[] concat(byte[]... parts) {
                    ByteArrayOutputStream out = new ByteArrayOutputStream();
                    for (byte[] part : parts) out.writeBytes(part);
                    return out.toByteArray();
                }
            }
      # x-codeSamples: end
      description: |
        The credit as it stands, and the terms of a deposit for this address
        now: where to send, the least this address may send, and whether we
        will pay the network for the deposit and what we take for it.

        The terms can change at any time, so read them before each deposit
        rather than keeping them. A deposit claimed to a treasury address we
        have since replaced is still accepted.

        Signed by the account's key, or read with a read token:
        `Authorization: Bearer prt_…` in place of `issued` and `signature`.
        The token comes from `POST /{address}/tokens`; this response never
        carries one.
      security: [{ signature: [] }, { readToken: [] }]
      parameters:
        - $ref: '#/components/parameters/Address'
        - $ref: '#/components/parameters/ReadIssued'
      responses:
        '200':
          description: The account
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Account' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/UnauthorizedRead' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /{address}/tokens:
    post:
      tags: [Read tokens]
      summary: A read token, so reads need no key
      operationId: mintToken
      # x-codeSamples: generated by scripts/prepaid-samples.mjs from scripts/prepaid-samples/; edit there
      x-codeSamples:
        - lang: shell
          label: cURL
          source: |
            # POST /{address}/tokens: a read token, so reads need no key
            # Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid
            # ACCOUNT is the address of the account.
            # curl cannot sign. sign.mjs signs with ACCOUNT_KEY (hex) and prints the query to send:
            #   curl -sO https://crypto.dashing.ws/developers/prepaid/clients/sign.mjs && npm install tronweb@6
            QUERY=$(node sign.mjs POST "/prepaid/$ACCOUNT/tokens")
            READ_TOKEN=$(curl -s -X POST "https://api.crypto.dashing.ws/prepaid/$ACCOUNT/tokens?$QUERY" | sed -E 's/.*"token":"([^"]*)".*/\1/')
            # The answer is {"token":"prt_…"}, shown once: keep it.

            # Reads with it need no key and no signature.
            curl -s "https://api.crypto.dashing.ws/prepaid/$ACCOUNT" -H "Authorization: Bearer $READ_TOKEN"
        - lang: javascript
          label: Node.js
          source: |
            // POST /{address}/tokens: a read token, so reads need no key
            // Node 18 or later:  npm install tronweb@6
            import { createHash } from 'node:crypto';
            import { TronWeb } from 'tronweb';

            // Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
            const API = 'https://api.crypto.dashing.ws/prepaid';
            const NODE = 'https://api.trongrid.io';
            const ACCOUNT_KEY = process.env.ACCOUNT_KEY; // hex; the account's key signs every request
            const ACCOUNT = TronWeb.address.fromPrivateKey(ACCOUNT_KEY);
            const tronWeb = new TronWeb({ fullHost: NODE });

            // Every request is signed by the account's key. The signed message is five lines:
            //   Dashing Crypto prepaid v1
            //   METHOD
            //   the path as sent, network prefix included     /prepaid/T…/transfers
            //   the query without signature, encoded, sorted  issued=1790467200&limit=25
            //   SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
            // signed as a TRON message (TIP-191), which is what signMessageV2 does.
            async function call(method, path, { query = {}, body } = {}) {
              const url = new URL(`${API}/${ACCOUNT}${path}`);
              const encode = (s) =>
                encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
              const canonical = Object.entries({ ...query, issued: Math.floor(Date.now() / 1000) })
                .map(([name, value]) => [encode(name), encode(String(value))])
                .sort(([a, x], [b, y]) => (a === b ? (x < y ? -1 : 1) : a < b ? -1 : 1))
                .map(([name, value]) => `${name}=${value}`)
                .join('&');
              const text = body === undefined ? '' : JSON.stringify(body);
              const message = [
                'Dashing Crypto prepaid v1',
                method,
                url.pathname,
                canonical,
                createHash('sha256').update(text, 'utf8').digest('hex'),
              ].join('\n');
              const signature = (await tronWeb.trx.signMessageV2(message, ACCOUNT_KEY)).slice(2); // r‖s‖v, hex

              const response = await fetch(`${url}?${canonical}&signature=${signature}`, {
                method,
                headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
                body: body === undefined ? undefined : text, // the bytes that were hashed, unchanged
              });
              if (response.status === 204) return null; // a DELETE has no body
              const json = await response.json();
              if (!response.ok) throw new Error(`${response.status} ${json.code}: ${json.message}`);
              return json;
            }

            // Signed once by the account's key. The token is shown once; keep it with the app's settings.
            const { token } = await call('POST', '/tokens');

            // Reads with it need no key and no signature: the account, the history, one transaction.
            async function read(path) {
              const response = await fetch(`${API}/${ACCOUNT}${path}`, { headers: { Authorization: `Bearer ${token}` } });
              const json = await response.json();
              // TOKEN_EXPIRED: unused for 180 days, or revoked. Sign POST /tokens again for a new one.
              if (!response.ok) throw new Error(`${response.status} ${json.code}: ${json.message}`);
              return json;
            }

            console.log((await read('')).credit);
            console.log((await read('/transactions?limit=5')).items);
        - lang: python
          label: Python
          source: |
            # POST /{address}/tokens: a read token, so reads need no key
            # Python 3.9 or later:  pip install coincurve pycryptodome base58 requests
            import hashlib
            import json
            import os
            import time
            from urllib.parse import quote, urlsplit

            import base58
            import requests
            from Crypto.Hash import keccak
            from coincurve import PrivateKey

            # Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
            API = "https://api.crypto.dashing.ws/prepaid"
            NODE = "https://api.trongrid.io"
            ACCOUNT_KEY = os.environ["ACCOUNT_KEY"]  # hex; the account's key signs every request


            def keccak256(data: bytes) -> bytes:
                return keccak.new(digest_bits=256, data=data).digest()


            def address_of(private_key_hex: str) -> str:
                """0x41 and the last 20 bytes of keccak(public key), in base58check."""
                public = PrivateKey(bytes.fromhex(private_key_hex)).public_key.format(compressed=False)[1:]
                return base58.b58encode_check(b"\x41" + keccak256(public)[-20:]).decode()


            ACCOUNT = address_of(ACCOUNT_KEY)


            def call(method: str, path: str, query: dict = None, body: dict = None) -> dict:
                """One request, signed by the account's key.

                The signed message is five lines:
                  Dashing Crypto prepaid v1
                  METHOD
                  the path as sent, network prefix included     /prepaid/T.../transfers
                  the query without signature, encoded, sorted  issued=1790467200&limit=25
                  SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
                signed as a TRON message (TIP-191): keccak-256 over "\\x19TRON Signed Message:\\n",
                the message's length in bytes and the message; secp256k1; r || s || v, v 27 or 28.
                """
                full_path = f"{urlsplit(API).path}/{ACCOUNT}{path}"
                params = dict(query or {}, issued=int(time.time()))
                encode = lambda value: quote(str(value), safe="-._~")  # RFC 3986
                canonical = "&".join(f"{k}={v}" for k, v in sorted((encode(k), encode(v)) for k, v in params.items()))
                data = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
                message = "\n".join([
                    "Dashing Crypto prepaid v1",
                    method,
                    full_path,
                    canonical,
                    hashlib.sha256(data).hexdigest(),
                ]).encode()

                digest = keccak256(b"\x19TRON Signed Message:\n" + str(len(message)).encode() + message)
                raw = PrivateKey(bytes.fromhex(ACCOUNT_KEY)).sign_recoverable(digest, hasher=None)
                signature = (raw[:64] + bytes([raw[64] + 27])).hex()

                origin = "{0.scheme}://{0.netloc}".format(urlsplit(API))
                response = requests.request(
                    method,
                    f"{origin}{full_path}?{canonical}&signature={signature}",
                    data=data if body is not None else None,  # the bytes that were hashed, unchanged
                    headers={"Content-Type": "application/json"} if body is not None else {},
                    timeout=30,
                )
                if response.status_code == 204:  # a DELETE has no body
                    return None
                result = response.json()
                if not response.ok:
                    raise RuntimeError(f"{response.status_code} {result['code']}: {result['message']}")
                return result


            # Signed once by the account's key. The token is shown once; keep it with the app's settings.
            token = call("POST", "/tokens")["token"]


            def read(path: str, query: dict = None) -> dict:
                """A read with the token: no key and no signature."""
                response = requests.get(
                    f"{API}/{ACCOUNT}{path}",
                    params=query,
                    headers={"Authorization": f"Bearer {token}"},
                    timeout=30,
                )
                result = response.json()
                # TOKEN_EXPIRED: unused for 180 days, or revoked. Sign POST /tokens again for a new one.
                if not response.ok:
                    raise RuntimeError(f"{response.status_code} {result['code']}: {result['message']}")
                return result


            print(read("")["credit"])
            print(read("/transactions", {"limit": 5})["items"])
        - lang: java
          label: Java
          source: |
            // POST /{address}/tokens: a read token, so reads need no key
            // Java 17 or later, with org.web3j:crypto:5.0.0 and com.fasterxml.jackson.core:jackson-databind
            import com.fasterxml.jackson.databind.JsonNode;
            import com.fasterxml.jackson.databind.ObjectMapper;
            import java.io.ByteArrayOutputStream;
            import java.math.BigDecimal;
            import java.math.BigInteger;
            import java.net.URI;
            import java.net.http.HttpClient;
            import java.net.http.HttpRequest;
            import java.net.http.HttpResponse;
            import java.nio.charset.StandardCharsets;
            import java.util.Arrays;
            import java.util.Comparator;
            import java.util.Map;
            import java.util.stream.Collectors;
            import org.web3j.crypto.ECKeyPair;
            import org.web3j.crypto.Hash;
            import org.web3j.crypto.Keys;
            import org.web3j.crypto.Sign;
            import org.web3j.utils.Numeric;

            public class Prepaid {

                // Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
                static final String API = "https://api.crypto.dashing.ws/prepaid";
                static final String NODE = "https://api.trongrid.io";
                static final ECKeyPair ACCOUNT_KEY = key(System.getenv("ACCOUNT_KEY")); // signs every request
                static final String ACCOUNT = addressOf(ACCOUNT_KEY);
                static final ObjectMapper JSON = new ObjectMapper();
                static final HttpClient HTTP = HttpClient.newHttpClient();

                public static void main(String[] args) throws Exception {
            // Signed once by the account's key. The token is shown once; keep it with the app's settings.
            String token = call("POST", "/tokens", Map.of(), null).path("token").asText();

            // Reads with it need no key and no signature: the account, the history, one transaction.
            for (String path : new String[] {"", "/transactions?limit=5"}) {
                HttpResponse<String> response = HTTP.send(
                        HttpRequest.newBuilder(URI.create(API + "/" + ACCOUNT + path))
                                .header("Authorization", "Bearer " + token).GET().build(),
                        HttpResponse.BodyHandlers.ofString());
                JsonNode read = JSON.readTree(response.body());
                // TOKEN_EXPIRED: unused for 180 days, or revoked. Sign POST /tokens again for a new one.
                if (response.statusCode() >= 400) {
                    throw new IllegalStateException(response.statusCode() + " " + read.path("code").asText()
                            + ": " + read.path("message").asText());
                }
                System.out.println(read);
            }
                }

                /**
                 * One request, signed by the account's key. The signed message is five lines:
                 *
                 * <pre>
                 *   Dashing Crypto prepaid v1
                 *   METHOD
                 *   the path as sent, network prefix included     /prepaid/T…/transfers
                 *   the query without signature, encoded, sorted  issued=1790467200&amp;limit=25
                 *   SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
                 * </pre>
                 *
                 * signed as a TRON message (TIP-191): keccak-256 over "\x19TRON Signed Message:\n", the
                 * message's length in bytes and the message; secp256k1; r‖s‖v with v 27 or 28.
                 */
                static JsonNode call(String method, String path, Map<String, String> query, Object body) throws Exception {
                    URI api = URI.create(API);
                    String fullPath = api.getPath() + "/" + ACCOUNT + path;

                    Map<String, String> params = new java.util.HashMap<>(query);
                    params.put("issued", Long.toString(System.currentTimeMillis() / 1000));
                    String canonical = params.entrySet().stream()
                            .map(e -> new String[] {encode(e.getKey()), encode(e.getValue())})
                            .sorted(Comparator.<String[], String>comparing(p -> p[0]).thenComparing(p -> p[1]))
                            .map(p -> p[0] + "=" + p[1])
                            .collect(Collectors.joining("&"));

                    byte[] bytes = body == null ? new byte[0] : JSON.writeValueAsBytes(body);
                    byte[] message = String.join("\n",
                            "Dashing Crypto prepaid v1",
                            method,
                            fullPath,
                            canonical,
                            Numeric.toHexStringNoPrefix(Hash.sha256(bytes))).getBytes(StandardCharsets.UTF_8);
                    byte[] digest = Hash.sha3(concat( // keccak-256
                            ("\u0019TRON Signed Message:\n" + message.length).getBytes(StandardCharsets.UTF_8), message));
                    Sign.SignatureData sig = Sign.signMessage(digest, ACCOUNT_KEY, false); // v is 27 or 28
                    String signature = Numeric.toHexStringNoPrefix(concat(sig.getR(), sig.getS(), sig.getV()));

                    HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(
                            api.getScheme() + "://" + api.getAuthority() + fullPath + "?" + canonical + "&signature=" + signature));
                    if (body == null) {
                        request.method(method, HttpRequest.BodyPublishers.noBody());
                    } else { // the bytes that were hashed, unchanged
                        request.header("Content-Type", "application/json").method(method, HttpRequest.BodyPublishers.ofByteArray(bytes));
                    }
                    HttpResponse<String> response = HTTP.send(request.build(), HttpResponse.BodyHandlers.ofString());
                    if (response.statusCode() == 204) {
                        return null; // a DELETE has no body
                    }
                    JsonNode json = JSON.readTree(response.body());
                    if (response.statusCode() >= 400) {
                        throw new IllegalStateException(
                                response.statusCode() + " " + json.path("code").asText() + ": " + json.path("message").asText());
                    }
                    return json;
                }

                /** RFC 3986: A–Z a–z 0–9 - . _ ~ stay; every other byte is %XX in upper-case hex. */
                static String encode(String value) {
                    StringBuilder out = new StringBuilder();
                    for (byte b : value.getBytes(StandardCharsets.UTF_8)) {
                        int c = b & 0xFF;
                        boolean unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
                                || c == '-' || c == '.' || c == '_' || c == '~';
                        out.append(unreserved ? String.valueOf((char) c) : String.format("%%%02X", c));
                    }
                    return out.toString();
                }

                static final String BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";

                static ECKeyPair key(String hex) {
                    return ECKeyPair.create(new BigInteger(hex, 16));
                }

                /** 0x41 and the last 20 bytes of keccak(public key), in base58check. */
                static String addressOf(ECKeyPair key) {
                    byte[] body = concat(new byte[] {0x41}, Numeric.hexStringToByteArray(Keys.getAddress(key.getPublicKey())));
                    byte[] bytes = concat(body, Arrays.copyOf(Hash.sha256(Hash.sha256(body)), 4));
                    StringBuilder out = new StringBuilder();
                    for (BigInteger v = new BigInteger(1, bytes); v.signum() > 0; v = v.divide(BigInteger.valueOf(58))) {
                        out.append(BASE58.charAt(v.mod(BigInteger.valueOf(58)).intValue()));
                    }
                    return out.reverse().toString();
                }

                static byte[] concat(byte[]... parts) {
                    ByteArrayOutputStream out = new ByteArrayOutputStream();
                    for (byte[] part : parts) out.writeBytes(part);
                    return out.toByteArray();
                }
            }
      # x-codeSamples: end
      description: |
        A token that reads this account, for a client that should not reach
        for the key to show a figure. Send it as `Authorization: Bearer prt_…`
        on any `GET` under the address, in place of `issued` and `signature`.

        Shown once: we keep only its hash. It stays good until it goes 180
        days unused, and every read starts the 180 days again. One that lapsed
        or was revoked answers `401` with `TOKEN_EXPIRED`; call this again for
        a new one.

        Always signed by the key: a token never makes another. An address
        holds up to ten; this call drops the one read least recently when it
        would be the eleventh.
      parameters:
        - $ref: '#/components/parameters/Address'
        - $ref: '#/components/parameters/Issued'
        - $ref: '#/components/parameters/Signature'
      responses:
        '201':
          description: The token
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReadToken' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Read tokens]
      summary: End read tokens
      operationId: revokeTokens
      # x-codeSamples: generated by scripts/prepaid-samples.mjs from scripts/prepaid-samples/; edit there
      x-codeSamples:
        - lang: shell
          label: cURL
          source: |
            # DELETE /{address}/tokens: end read tokens
            # Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid
            # ACCOUNT is the address of the account. With a read token, this ends that token and no other.
            curl -s -X DELETE "https://api.crypto.dashing.ws/prepaid/$ACCOUNT/tokens" -H "Authorization: Bearer $READ_TOKEN"

            # Signed by the key, it ends every token the account holds: for a phone that was lost.
            # curl cannot sign. sign.mjs signs with ACCOUNT_KEY (hex) and prints the query to send:
            #   curl -sO https://crypto.dashing.ws/developers/prepaid/clients/sign.mjs && npm install tronweb@6
            QUERY=$(node sign.mjs DELETE "/prepaid/$ACCOUNT/tokens")
            curl -s -X DELETE "https://api.crypto.dashing.ws/prepaid/$ACCOUNT/tokens?$QUERY"
            # Both answer 204 with no body.
        - lang: javascript
          label: Node.js
          source: |
            // DELETE /{address}/tokens: end read tokens
            // Node 18 or later:  npm install tronweb@6
            import { createHash } from 'node:crypto';
            import { TronWeb } from 'tronweb';

            // Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
            const API = 'https://api.crypto.dashing.ws/prepaid';
            const NODE = 'https://api.trongrid.io';
            const ACCOUNT_KEY = process.env.ACCOUNT_KEY; // hex; the account's key signs every request
            const ACCOUNT = TronWeb.address.fromPrivateKey(ACCOUNT_KEY);
            const tronWeb = new TronWeb({ fullHost: NODE });

            // Every request is signed by the account's key. The signed message is five lines:
            //   Dashing Crypto prepaid v1
            //   METHOD
            //   the path as sent, network prefix included     /prepaid/T…/transfers
            //   the query without signature, encoded, sorted  issued=1790467200&limit=25
            //   SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
            // signed as a TRON message (TIP-191), which is what signMessageV2 does.
            async function call(method, path, { query = {}, body } = {}) {
              const url = new URL(`${API}/${ACCOUNT}${path}`);
              const encode = (s) =>
                encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
              const canonical = Object.entries({ ...query, issued: Math.floor(Date.now() / 1000) })
                .map(([name, value]) => [encode(name), encode(String(value))])
                .sort(([a, x], [b, y]) => (a === b ? (x < y ? -1 : 1) : a < b ? -1 : 1))
                .map(([name, value]) => `${name}=${value}`)
                .join('&');
              const text = body === undefined ? '' : JSON.stringify(body);
              const message = [
                'Dashing Crypto prepaid v1',
                method,
                url.pathname,
                canonical,
                createHash('sha256').update(text, 'utf8').digest('hex'),
              ].join('\n');
              const signature = (await tronWeb.trx.signMessageV2(message, ACCOUNT_KEY)).slice(2); // r‖s‖v, hex

              const response = await fetch(`${url}?${canonical}&signature=${signature}`, {
                method,
                headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
                body: body === undefined ? undefined : text, // the bytes that were hashed, unchanged
              });
              if (response.status === 204) return null; // a DELETE has no body
              const json = await response.json();
              if (!response.ok) throw new Error(`${response.status} ${json.code}: ${json.message}`);
              return json;
            }

            // Signed by the account's key: ends every read token the account holds, for a phone that was lost.
            await call('DELETE', '/tokens');

            // A token can end itself, with no key:
            //   await fetch(`${API}/${ACCOUNT}/tokens`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
        - lang: python
          label: Python
          source: |
            # DELETE /{address}/tokens: end read tokens
            # Python 3.9 or later:  pip install coincurve pycryptodome base58 requests
            import hashlib
            import json
            import os
            import time
            from urllib.parse import quote, urlsplit

            import base58
            import requests
            from Crypto.Hash import keccak
            from coincurve import PrivateKey

            # Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
            API = "https://api.crypto.dashing.ws/prepaid"
            NODE = "https://api.trongrid.io"
            ACCOUNT_KEY = os.environ["ACCOUNT_KEY"]  # hex; the account's key signs every request


            def keccak256(data: bytes) -> bytes:
                return keccak.new(digest_bits=256, data=data).digest()


            def address_of(private_key_hex: str) -> str:
                """0x41 and the last 20 bytes of keccak(public key), in base58check."""
                public = PrivateKey(bytes.fromhex(private_key_hex)).public_key.format(compressed=False)[1:]
                return base58.b58encode_check(b"\x41" + keccak256(public)[-20:]).decode()


            ACCOUNT = address_of(ACCOUNT_KEY)


            def call(method: str, path: str, query: dict = None, body: dict = None) -> dict:
                """One request, signed by the account's key.

                The signed message is five lines:
                  Dashing Crypto prepaid v1
                  METHOD
                  the path as sent, network prefix included     /prepaid/T.../transfers
                  the query without signature, encoded, sorted  issued=1790467200&limit=25
                  SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
                signed as a TRON message (TIP-191): keccak-256 over "\\x19TRON Signed Message:\\n",
                the message's length in bytes and the message; secp256k1; r || s || v, v 27 or 28.
                """
                full_path = f"{urlsplit(API).path}/{ACCOUNT}{path}"
                params = dict(query or {}, issued=int(time.time()))
                encode = lambda value: quote(str(value), safe="-._~")  # RFC 3986
                canonical = "&".join(f"{k}={v}" for k, v in sorted((encode(k), encode(v)) for k, v in params.items()))
                data = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
                message = "\n".join([
                    "Dashing Crypto prepaid v1",
                    method,
                    full_path,
                    canonical,
                    hashlib.sha256(data).hexdigest(),
                ]).encode()

                digest = keccak256(b"\x19TRON Signed Message:\n" + str(len(message)).encode() + message)
                raw = PrivateKey(bytes.fromhex(ACCOUNT_KEY)).sign_recoverable(digest, hasher=None)
                signature = (raw[:64] + bytes([raw[64] + 27])).hex()

                origin = "{0.scheme}://{0.netloc}".format(urlsplit(API))
                response = requests.request(
                    method,
                    f"{origin}{full_path}?{canonical}&signature={signature}",
                    data=data if body is not None else None,  # the bytes that were hashed, unchanged
                    headers={"Content-Type": "application/json"} if body is not None else {},
                    timeout=30,
                )
                if response.status_code == 204:  # a DELETE has no body
                    return None
                result = response.json()
                if not response.ok:
                    raise RuntimeError(f"{response.status_code} {result['code']}: {result['message']}")
                return result


            # Signed by the account's key: ends every read token the account holds, for a phone that was lost.
            call("DELETE", "/tokens")

            # A token can end itself, with no key:
            #   requests.delete(f"{API}/{ACCOUNT}/tokens", headers={"Authorization": f"Bearer {token}"}, timeout=30)
        - lang: java
          label: Java
          source: |
            // DELETE /{address}/tokens: end read tokens
            // Java 17 or later, with org.web3j:crypto:5.0.0 and com.fasterxml.jackson.core:jackson-databind
            import com.fasterxml.jackson.databind.JsonNode;
            import com.fasterxml.jackson.databind.ObjectMapper;
            import java.io.ByteArrayOutputStream;
            import java.math.BigDecimal;
            import java.math.BigInteger;
            import java.net.URI;
            import java.net.http.HttpClient;
            import java.net.http.HttpRequest;
            import java.net.http.HttpResponse;
            import java.nio.charset.StandardCharsets;
            import java.util.Arrays;
            import java.util.Comparator;
            import java.util.Map;
            import java.util.stream.Collectors;
            import org.web3j.crypto.ECKeyPair;
            import org.web3j.crypto.Hash;
            import org.web3j.crypto.Keys;
            import org.web3j.crypto.Sign;
            import org.web3j.utils.Numeric;

            public class Prepaid {

                // Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
                static final String API = "https://api.crypto.dashing.ws/prepaid";
                static final String NODE = "https://api.trongrid.io";
                static final ECKeyPair ACCOUNT_KEY = key(System.getenv("ACCOUNT_KEY")); // signs every request
                static final String ACCOUNT = addressOf(ACCOUNT_KEY);
                static final ObjectMapper JSON = new ObjectMapper();
                static final HttpClient HTTP = HttpClient.newHttpClient();

                public static void main(String[] args) throws Exception {
            // Signed by the account's key: ends every read token the account holds, for a phone that was lost.
            call("DELETE", "/tokens", Map.of(), null);

            // A token can end itself, with no key:
            //   HTTP.send(HttpRequest.newBuilder(URI.create(API + "/" + ACCOUNT + "/tokens"))
            //           .header("Authorization", "Bearer " + token).DELETE().build(), HttpResponse.BodyHandlers.discarding());
                }

                /**
                 * One request, signed by the account's key. The signed message is five lines:
                 *
                 * <pre>
                 *   Dashing Crypto prepaid v1
                 *   METHOD
                 *   the path as sent, network prefix included     /prepaid/T…/transfers
                 *   the query without signature, encoded, sorted  issued=1790467200&amp;limit=25
                 *   SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
                 * </pre>
                 *
                 * signed as a TRON message (TIP-191): keccak-256 over "\x19TRON Signed Message:\n", the
                 * message's length in bytes and the message; secp256k1; r‖s‖v with v 27 or 28.
                 */
                static JsonNode call(String method, String path, Map<String, String> query, Object body) throws Exception {
                    URI api = URI.create(API);
                    String fullPath = api.getPath() + "/" + ACCOUNT + path;

                    Map<String, String> params = new java.util.HashMap<>(query);
                    params.put("issued", Long.toString(System.currentTimeMillis() / 1000));
                    String canonical = params.entrySet().stream()
                            .map(e -> new String[] {encode(e.getKey()), encode(e.getValue())})
                            .sorted(Comparator.<String[], String>comparing(p -> p[0]).thenComparing(p -> p[1]))
                            .map(p -> p[0] + "=" + p[1])
                            .collect(Collectors.joining("&"));

                    byte[] bytes = body == null ? new byte[0] : JSON.writeValueAsBytes(body);
                    byte[] message = String.join("\n",
                            "Dashing Crypto prepaid v1",
                            method,
                            fullPath,
                            canonical,
                            Numeric.toHexStringNoPrefix(Hash.sha256(bytes))).getBytes(StandardCharsets.UTF_8);
                    byte[] digest = Hash.sha3(concat( // keccak-256
                            ("\u0019TRON Signed Message:\n" + message.length).getBytes(StandardCharsets.UTF_8), message));
                    Sign.SignatureData sig = Sign.signMessage(digest, ACCOUNT_KEY, false); // v is 27 or 28
                    String signature = Numeric.toHexStringNoPrefix(concat(sig.getR(), sig.getS(), sig.getV()));

                    HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(
                            api.getScheme() + "://" + api.getAuthority() + fullPath + "?" + canonical + "&signature=" + signature));
                    if (body == null) {
                        request.method(method, HttpRequest.BodyPublishers.noBody());
                    } else { // the bytes that were hashed, unchanged
                        request.header("Content-Type", "application/json").method(method, HttpRequest.BodyPublishers.ofByteArray(bytes));
                    }
                    HttpResponse<String> response = HTTP.send(request.build(), HttpResponse.BodyHandlers.ofString());
                    if (response.statusCode() == 204) {
                        return null; // a DELETE has no body
                    }
                    JsonNode json = JSON.readTree(response.body());
                    if (response.statusCode() >= 400) {
                        throw new IllegalStateException(
                                response.statusCode() + " " + json.path("code").asText() + ": " + json.path("message").asText());
                    }
                    return json;
                }

                /** RFC 3986: A–Z a–z 0–9 - . _ ~ stay; every other byte is %XX in upper-case hex. */
                static String encode(String value) {
                    StringBuilder out = new StringBuilder();
                    for (byte b : value.getBytes(StandardCharsets.UTF_8)) {
                        int c = b & 0xFF;
                        boolean unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
                                || c == '-' || c == '.' || c == '_' || c == '~';
                        out.append(unreserved ? String.valueOf((char) c) : String.format("%%%02X", c));
                    }
                    return out.toString();
                }

                static final String BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";

                static ECKeyPair key(String hex) {
                    return ECKeyPair.create(new BigInteger(hex, 16));
                }

                /** 0x41 and the last 20 bytes of keccak(public key), in base58check. */
                static String addressOf(ECKeyPair key) {
                    byte[] body = concat(new byte[] {0x41}, Numeric.hexStringToByteArray(Keys.getAddress(key.getPublicKey())));
                    byte[] bytes = concat(body, Arrays.copyOf(Hash.sha256(Hash.sha256(body)), 4));
                    StringBuilder out = new StringBuilder();
                    for (BigInteger v = new BigInteger(1, bytes); v.signum() > 0; v = v.divide(BigInteger.valueOf(58))) {
                        out.append(BASE58.charAt(v.mod(BigInteger.valueOf(58)).intValue()));
                    }
                    return out.reverse().toString();
                }

                static byte[] concat(byte[]... parts) {
                    ByteArrayOutputStream out = new ByteArrayOutputStream();
                    for (byte[] part : parts) out.writeBytes(part);
                    return out.toByteArray();
                }
            }
      # x-codeSamples: end
      description: |
        With a read token, ends that token and no other. Signed by the key,
        ends every token the address holds on this network: for a phone that
        was lost, or a token that leaked.

        Ending a token that is already gone is not an error.
      security: [{ signature: [] }, { readToken: [] }]
      parameters:
        - $ref: '#/components/parameters/Address'
        - $ref: '#/components/parameters/ReadIssued'
      responses:
        '204':
          description: Ended
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/UnauthorizedRead' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /{address}/deposits:
    post:
      tags: [Deposits]
      summary: Put USDT on the credit
      operationId: deposit
      # x-codeSamples: generated by scripts/prepaid-samples.mjs from scripts/prepaid-samples/; edit there
      x-codeSamples:
        - lang: shell
          label: cURL
          source: |
            # POST /{address}/deposits: put USDT on the credit
            # Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid
            # ACCOUNT is the address of the account.
            # curl cannot sign. sign.mjs signs with ACCOUNT_KEY (hex) and prints the query to send:
            #   curl -sO https://crypto.dashing.ws/developers/prepaid/clients/sign.mjs && npm install tronweb@6

            # A · You broadcast it: claim your USDT transfer to deposit.treasury by its transaction id.
            BODY=$(printf '{"txId":"%s"}' "$TX_ID")
            QUERY=$(node sign.mjs POST "/prepaid/$ACCOUNT/deposits" "$BODY")
            curl -s -X POST "https://api.crypto.dashing.ws/prepaid/$ACCOUNT/deposits?$QUERY" \
              -H 'Content-Type: application/json' --data-raw "$BODY"
            # {"id":"…","state":"CREDITED","txId":"…","amount":"20.00","credited":"20.00","credit":"30.00",…}

            # B · We broadcast it: send {"signedTransaction":"<hex>"} instead, your transfer to the treasury
            # signed and not broadcast. curl cannot build one; the Node.js, Python and Java tabs do.
        - lang: javascript
          label: Node.js
          source: |
            // POST /{address}/deposits: put USDT on the credit
            // Node 18 or later:  npm install tronweb@6
            import { createHash } from 'node:crypto';
            import { TronWeb } from 'tronweb';

            // Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
            const API = 'https://api.crypto.dashing.ws/prepaid';
            const NODE = 'https://api.trongrid.io';
            const ACCOUNT_KEY = process.env.ACCOUNT_KEY; // hex; the account's key signs every request
            const ACCOUNT = TronWeb.address.fromPrivateKey(ACCOUNT_KEY);
            const tronWeb = new TronWeb({ fullHost: NODE });

            // Every request is signed by the account's key. The signed message is five lines:
            //   Dashing Crypto prepaid v1
            //   METHOD
            //   the path as sent, network prefix included     /prepaid/T…/transfers
            //   the query without signature, encoded, sorted  issued=1790467200&limit=25
            //   SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
            // signed as a TRON message (TIP-191), which is what signMessageV2 does.
            async function call(method, path, { query = {}, body } = {}) {
              const url = new URL(`${API}/${ACCOUNT}${path}`);
              const encode = (s) =>
                encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
              const canonical = Object.entries({ ...query, issued: Math.floor(Date.now() / 1000) })
                .map(([name, value]) => [encode(name), encode(String(value))])
                .sort(([a, x], [b, y]) => (a === b ? (x < y ? -1 : 1) : a < b ? -1 : 1))
                .map(([name, value]) => `${name}=${value}`)
                .join('&');
              const text = body === undefined ? '' : JSON.stringify(body);
              const message = [
                'Dashing Crypto prepaid v1',
                method,
                url.pathname,
                canonical,
                createHash('sha256').update(text, 'utf8').digest('hex'),
              ].join('\n');
              const signature = (await tronWeb.trx.signMessageV2(message, ACCOUNT_KEY)).slice(2); // r‖s‖v, hex

              const response = await fetch(`${url}?${canonical}&signature=${signature}`, {
                method,
                headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
                body: body === undefined ? undefined : text, // the bytes that were hashed, unchanged
              });
              if (response.status === 204) return null; // a DELETE has no body
              const json = await response.json();
              if (!response.ok) throw new Error(`${response.status} ${json.code}: ${json.message}`);
              return json;
            }

            // A USDT transfer the sender signs with their own key: a TRC20 transfer on the USDT contract,
            // fee_limit 20 TRX, five minutes to expiry (a node stamps one minute; the API wants two to ten).
            // Returns the whole signed Transaction as hex, which is what the API takes.
            async function signedUsdtTransfer(senderKey, to, amount) {
              const USDT = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; // Mainnet's; Nile's is TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf
              const from = TronWeb.address.fromPrivateKey(senderKey);
              const [whole, fraction = ''] = amount.split('.');
              const units = (BigInt(whole) * 1_000_000n + BigInt(fraction.padEnd(6, '0'))).toString(); // six decimals
              const { transaction } = await tronWeb.transactionBuilder.triggerSmartContract(
                USDT,
                'transfer(address,uint256)',
                { feeLimit: 20_000_000 },
                [
                  { type: 'address', value: to },
                  { type: 'uint256', value: units },
                ],
                from,
              );
              const extended = await tronWeb.transactionBuilder.extendExpiration(transaction, 240); // 60 s + 240 s
              const signed = await tronWeb.trx.sign(extended, senderKey);

              // Transaction { raw_data = 1; signature = 2 }, as protobuf, as hex.
              const varint = (n) => {
                let out = '';
                for (; n > 0x7f; n >>>= 7) out += ((n & 0x7f) | 0x80).toString(16).padStart(2, '0');
                return out + n.toString(16).padStart(2, '0');
              };
              const field = (tag, hex) => tag + varint(hex.length / 2) + hex;
              return {
                txID: signed.txID,
                hex: field('0a', signed.raw_data_hex) + signed.signature.map((s) => field('12', s)).join(''),
              };
            }

            // B · We broadcast it: sign the transfer to the treasury and hand it over.
            const { deposit } = await call('GET', '');
            const signed = await signedUsdtTransfer(ACCOUNT_KEY, deposit.treasury, deposit.minimum);
            const result = await call('POST', '/deposits', {
              body: { signedTransaction: signed.hex, maxSponsoredFee: deposit.sponsored.fee },
            });
            console.log(result.id, result.state); // SPONSORING; follow it at /transactions/{id} until CREDITED

            // A · You broadcast it yourself and paid the network: claim it by its transaction id.
            // await call('POST', '/deposits', { body: { txId: '7c2d3e8f…' } });
        - lang: python
          label: Python
          source: |
            # POST /{address}/deposits: put USDT on the credit
            # Python 3.9 or later:  pip install coincurve pycryptodome base58 requests
            import hashlib
            import json
            import os
            import time
            from urllib.parse import quote, urlsplit

            import base58
            import requests
            from Crypto.Hash import keccak
            from coincurve import PrivateKey

            # Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
            API = "https://api.crypto.dashing.ws/prepaid"
            NODE = "https://api.trongrid.io"
            ACCOUNT_KEY = os.environ["ACCOUNT_KEY"]  # hex; the account's key signs every request


            def keccak256(data: bytes) -> bytes:
                return keccak.new(digest_bits=256, data=data).digest()


            def address_of(private_key_hex: str) -> str:
                """0x41 and the last 20 bytes of keccak(public key), in base58check."""
                public = PrivateKey(bytes.fromhex(private_key_hex)).public_key.format(compressed=False)[1:]
                return base58.b58encode_check(b"\x41" + keccak256(public)[-20:]).decode()


            ACCOUNT = address_of(ACCOUNT_KEY)


            def call(method: str, path: str, query: dict = None, body: dict = None) -> dict:
                """One request, signed by the account's key.

                The signed message is five lines:
                  Dashing Crypto prepaid v1
                  METHOD
                  the path as sent, network prefix included     /prepaid/T.../transfers
                  the query without signature, encoded, sorted  issued=1790467200&limit=25
                  SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
                signed as a TRON message (TIP-191): keccak-256 over "\\x19TRON Signed Message:\\n",
                the message's length in bytes and the message; secp256k1; r || s || v, v 27 or 28.
                """
                full_path = f"{urlsplit(API).path}/{ACCOUNT}{path}"
                params = dict(query or {}, issued=int(time.time()))
                encode = lambda value: quote(str(value), safe="-._~")  # RFC 3986
                canonical = "&".join(f"{k}={v}" for k, v in sorted((encode(k), encode(v)) for k, v in params.items()))
                data = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
                message = "\n".join([
                    "Dashing Crypto prepaid v1",
                    method,
                    full_path,
                    canonical,
                    hashlib.sha256(data).hexdigest(),
                ]).encode()

                digest = keccak256(b"\x19TRON Signed Message:\n" + str(len(message)).encode() + message)
                raw = PrivateKey(bytes.fromhex(ACCOUNT_KEY)).sign_recoverable(digest, hasher=None)
                signature = (raw[:64] + bytes([raw[64] + 27])).hex()

                origin = "{0.scheme}://{0.netloc}".format(urlsplit(API))
                response = requests.request(
                    method,
                    f"{origin}{full_path}?{canonical}&signature={signature}",
                    data=data if body is not None else None,  # the bytes that were hashed, unchanged
                    headers={"Content-Type": "application/json"} if body is not None else {},
                    timeout=30,
                )
                if response.status_code == 204:  # a DELETE has no body
                    return None
                result = response.json()
                if not response.ok:
                    raise RuntimeError(f"{response.status_code} {result['code']}: {result['message']}")
                return result


            def signed_usdt_transfer(sender_key: str, to: str, amount: str) -> dict:
                """A USDT transfer the sender signs with their own key.

                A TRC20 transfer on the USDT contract, fee_limit 20 TRX, five minutes to expiry (a node stamps
                one minute; the API wants two to ten). Returns the whole signed Transaction as hex.
                """
                usdt = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"  # Mainnet's; Nile's is TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf
                whole, _, fraction = amount.partition(".")
                units = int(whole) * 1_000_000 + int(fraction.ljust(6, "0") or 0)
                parameter = base58.b58decode_check(to)[1:].rjust(32, b"\0").hex() + units.to_bytes(32, "big").hex()
                built = requests.post(f"{NODE}/wallet/triggersmartcontract", json={
                    "owner_address": address_of(sender_key),
                    "contract_address": usdt,
                    "function_selector": "transfer(address,uint256)",
                    "parameter": parameter,
                    "fee_limit": 20_000_000,
                    "call_value": 0,
                    "visible": True,
                }, timeout=30).json()["transaction"]

                # raw_data field 8 is the expiry in milliseconds; the transaction id is SHA-256 of raw_data.
                raw = with_expiration(bytes.fromhex(built["raw_data_hex"]), int(time.time() * 1000) + 5 * 60 * 1000)
                tx_id = hashlib.sha256(raw).digest()
                signature = PrivateKey(bytes.fromhex(sender_key)).sign_recoverable(tx_id, hasher=None)

                # Transaction { raw_data = 1; signature = 2 }, as protobuf.
                signed = b"\x0a" + varint(len(raw)) + raw + b"\x12" + varint(len(signature)) + signature
                return {"txID": tx_id.hex(), "hex": signed.hex()}


            def with_expiration(raw: bytes, expiration_ms: int) -> bytes:
                """raw_data with field 8 replaced; every other field is copied as it is."""
                out, at = bytearray(), 0
                while at < len(raw):
                    start = at
                    tag, at = read_varint(raw, at)
                    wire = tag & 7
                    if wire == 0:
                        _, at = read_varint(raw, at)
                    elif wire == 2:
                        length, at = read_varint(raw, at)
                        at += length
                    else:
                        at += 8 if wire == 1 else 4
                    out += varint(tag) + varint(expiration_ms) if (tag >> 3, wire) == (8, 0) else raw[start:at]
                return bytes(out)


            def varint(n: int) -> bytes:
                out = bytearray()
                while n > 0x7F:
                    out.append((n & 0x7F) | 0x80)
                    n >>= 7
                return bytes(out + bytes([n]))


            def read_varint(data: bytes, at: int):
                value = shift = 0
                while True:
                    byte = data[at]
                    at += 1
                    value |= (byte & 0x7F) << shift
                    if not byte & 0x80:
                        return value, at
                    shift += 7


            # B · We broadcast it: sign the transfer to the treasury and hand it over.
            terms = call("GET", "")["deposit"]
            signed = signed_usdt_transfer(ACCOUNT_KEY, terms["treasury"], terms["minimum"])
            result = call("POST", "/deposits", body={
                "signedTransaction": signed["hex"],
                "maxSponsoredFee": terms["sponsored"]["fee"],
            })
            print(result["id"], result["state"])  # SPONSORING; follow it at /transactions/{id} until CREDITED

            # A · You broadcast it yourself and paid the network: claim it by its transaction id.
            # call("POST", "/deposits", body={"txId": "7c2d3e8f..."})
        - lang: java
          label: Java
          source: |
            // POST /{address}/deposits: put USDT on the credit
            // Java 17 or later, with org.web3j:crypto:5.0.0 and com.fasterxml.jackson.core:jackson-databind
            import com.fasterxml.jackson.databind.JsonNode;
            import com.fasterxml.jackson.databind.ObjectMapper;
            import java.io.ByteArrayOutputStream;
            import java.math.BigDecimal;
            import java.math.BigInteger;
            import java.net.URI;
            import java.net.http.HttpClient;
            import java.net.http.HttpRequest;
            import java.net.http.HttpResponse;
            import java.nio.charset.StandardCharsets;
            import java.util.Arrays;
            import java.util.Comparator;
            import java.util.Map;
            import java.util.stream.Collectors;
            import org.web3j.crypto.ECKeyPair;
            import org.web3j.crypto.Hash;
            import org.web3j.crypto.Keys;
            import org.web3j.crypto.Sign;
            import org.web3j.utils.Numeric;

            public class Prepaid {

                // Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
                static final String API = "https://api.crypto.dashing.ws/prepaid";
                static final String NODE = "https://api.trongrid.io";
                static final ECKeyPair ACCOUNT_KEY = key(System.getenv("ACCOUNT_KEY")); // signs every request
                static final String ACCOUNT = addressOf(ACCOUNT_KEY);
                static final ObjectMapper JSON = new ObjectMapper();
                static final HttpClient HTTP = HttpClient.newHttpClient();

                public static void main(String[] args) throws Exception {
            // B · We broadcast it: sign the transfer to the treasury and hand it over.
            JsonNode terms = call("GET", "", Map.of(), null).path("deposit");
            String[] signed = signedUsdtTransfer(ACCOUNT_KEY, terms.path("treasury").asText(), terms.path("minimum").asText());
            JsonNode result = call("POST", "/deposits", Map.of(), Map.of(
                    "signedTransaction", signed[1],
                    "maxSponsoredFee", terms.path("sponsored").path("fee").asText()));
            System.out.println(result.path("id").asText() + " " + result.path("state").asText()); // SPONSORING

            // A · You broadcast it yourself and paid the network: claim it by its transaction id.
            // call("POST", "/deposits", Map.of(), Map.of("txId", "7c2d3e8f…"));
                }

                /**
                 * One request, signed by the account's key. The signed message is five lines:
                 *
                 * <pre>
                 *   Dashing Crypto prepaid v1
                 *   METHOD
                 *   the path as sent, network prefix included     /prepaid/T…/transfers
                 *   the query without signature, encoded, sorted  issued=1790467200&amp;limit=25
                 *   SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
                 * </pre>
                 *
                 * signed as a TRON message (TIP-191): keccak-256 over "\x19TRON Signed Message:\n", the
                 * message's length in bytes and the message; secp256k1; r‖s‖v with v 27 or 28.
                 */
                static JsonNode call(String method, String path, Map<String, String> query, Object body) throws Exception {
                    URI api = URI.create(API);
                    String fullPath = api.getPath() + "/" + ACCOUNT + path;

                    Map<String, String> params = new java.util.HashMap<>(query);
                    params.put("issued", Long.toString(System.currentTimeMillis() / 1000));
                    String canonical = params.entrySet().stream()
                            .map(e -> new String[] {encode(e.getKey()), encode(e.getValue())})
                            .sorted(Comparator.<String[], String>comparing(p -> p[0]).thenComparing(p -> p[1]))
                            .map(p -> p[0] + "=" + p[1])
                            .collect(Collectors.joining("&"));

                    byte[] bytes = body == null ? new byte[0] : JSON.writeValueAsBytes(body);
                    byte[] message = String.join("\n",
                            "Dashing Crypto prepaid v1",
                            method,
                            fullPath,
                            canonical,
                            Numeric.toHexStringNoPrefix(Hash.sha256(bytes))).getBytes(StandardCharsets.UTF_8);
                    byte[] digest = Hash.sha3(concat( // keccak-256
                            ("\u0019TRON Signed Message:\n" + message.length).getBytes(StandardCharsets.UTF_8), message));
                    Sign.SignatureData sig = Sign.signMessage(digest, ACCOUNT_KEY, false); // v is 27 or 28
                    String signature = Numeric.toHexStringNoPrefix(concat(sig.getR(), sig.getS(), sig.getV()));

                    HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(
                            api.getScheme() + "://" + api.getAuthority() + fullPath + "?" + canonical + "&signature=" + signature));
                    if (body == null) {
                        request.method(method, HttpRequest.BodyPublishers.noBody());
                    } else { // the bytes that were hashed, unchanged
                        request.header("Content-Type", "application/json").method(method, HttpRequest.BodyPublishers.ofByteArray(bytes));
                    }
                    HttpResponse<String> response = HTTP.send(request.build(), HttpResponse.BodyHandlers.ofString());
                    if (response.statusCode() == 204) {
                        return null; // a DELETE has no body
                    }
                    JsonNode json = JSON.readTree(response.body());
                    if (response.statusCode() >= 400) {
                        throw new IllegalStateException(
                                response.statusCode() + " " + json.path("code").asText() + ": " + json.path("message").asText());
                    }
                    return json;
                }

                /** RFC 3986: A–Z a–z 0–9 - . _ ~ stay; every other byte is %XX in upper-case hex. */
                static String encode(String value) {
                    StringBuilder out = new StringBuilder();
                    for (byte b : value.getBytes(StandardCharsets.UTF_8)) {
                        int c = b & 0xFF;
                        boolean unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
                                || c == '-' || c == '.' || c == '_' || c == '~';
                        out.append(unreserved ? String.valueOf((char) c) : String.format("%%%02X", c));
                    }
                    return out.toString();
                }

                /**
                 * A USDT transfer the sender signs with their own key: a TRC20 transfer on the USDT contract,
                 * fee_limit 20 TRX, five minutes to expiry (a node stamps one minute; the API wants two to ten).
                 * Returns {txID, the whole signed Transaction as hex}; the hex is what the API takes.
                 */
                static String[] signedUsdtTransfer(ECKeyPair sender, String to, String amount) throws Exception {
                    String usdt = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; // Mainnet's; Nile's is TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf
                    long units = new BigDecimal(amount).movePointRight(6).longValueExact();
                    byte[] recipient = Arrays.copyOfRange(base58Decode(to), 1, 21);
                    String parameter = Numeric.toHexStringNoPrefixZeroPadded(new BigInteger(1, recipient), 64)
                            + Numeric.toHexStringNoPrefixZeroPadded(BigInteger.valueOf(units), 64);
                    String build = JSON.writeValueAsString(Map.of(
                            "owner_address", addressOf(sender),
                            "contract_address", usdt,
                            "function_selector", "transfer(address,uint256)",
                            "parameter", parameter,
                            "fee_limit", 20_000_000,
                            "call_value", 0,
                            "visible", true));
                    JsonNode built = JSON.readTree(HTTP.send(
                            HttpRequest.newBuilder(URI.create(NODE + "/wallet/triggersmartcontract"))
                                    .header("Content-Type", "application/json")
                                    .POST(HttpRequest.BodyPublishers.ofString(build)).build(),
                            HttpResponse.BodyHandlers.ofString()).body()).path("transaction");

                    // raw_data field 8 is the expiry in milliseconds; the transaction id is SHA-256 of raw_data.
                    byte[] raw = withExpiration(Numeric.hexStringToByteArray(built.path("raw_data_hex").asText()),
                            System.currentTimeMillis() + 5 * 60 * 1000);
                    byte[] txId = Hash.sha256(raw);
                    Sign.SignatureData sig = Sign.signMessage(txId, sender, false);
                    byte[] signature = concat(sig.getR(), sig.getS(), sig.getV());

                    // Transaction { raw_data = 1; signature = 2 }, as protobuf.
                    byte[] signed = concat(new byte[] {0x0a}, varint(raw.length), raw, new byte[] {0x12}, varint(65), signature);
                    return new String[] {Numeric.toHexStringNoPrefix(txId), Numeric.toHexStringNoPrefix(signed)};
                }

                /** raw_data with field 8 replaced; every other field is copied as it is. */
                static byte[] withExpiration(byte[] raw, long expirationMillis) {
                    ByteArrayOutputStream out = new ByteArrayOutputStream();
                    int[] at = {0};
                    while (at[0] < raw.length) {
                        int start = at[0];
                        long tag = readVarint(raw, at);
                        int wire = (int) (tag & 7);
                        if (wire == 0) {
                            readVarint(raw, at);
                        } else if (wire == 2) {
                            int length = (int) readVarint(raw, at); // read first: `at[0] += readVarint(…)` loses the length's own bytes
                            at[0] += length;
                        } else {
                            at[0] += wire == 1 ? 8 : 4;
                        }
                        if (tag >>> 3 == 8 && wire == 0) out.writeBytes(concat(varint(tag), varint(expirationMillis)));
                        else out.write(raw, start, at[0] - start);
                    }
                    return out.toByteArray();
                }

                static byte[] base58Decode(String text) {
                    BigInteger value = BigInteger.ZERO;
                    for (char c : text.toCharArray()) {
                        value = value.multiply(BigInteger.valueOf(58)).add(BigInteger.valueOf(BASE58.indexOf(c)));
                    }
                    return Numeric.toBytesPadded(value, 25); // 0x41, 20 bytes, 4 bytes of checksum
                }

                static byte[] varint(long value) {
                    ByteArrayOutputStream out = new ByteArrayOutputStream();
                    for (; (value & ~0x7FL) != 0; value >>>= 7) out.write((int) ((value & 0x7F) | 0x80));
                    out.write((int) value);
                    return out.toByteArray();
                }

                static long readVarint(byte[] bytes, int[] at) {
                    long value = 0;
                    for (int shift = 0; ; shift += 7) {
                        byte b = bytes[at[0]++];
                        value |= (long) (b & 0x7F) << shift;
                        if ((b & 0x80) == 0) return value;
                    }
                }

                static final String BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";

                static ECKeyPair key(String hex) {
                    return ECKeyPair.create(new BigInteger(hex, 16));
                }

                /** 0x41 and the last 20 bytes of keccak(public key), in base58check. */
                static String addressOf(ECKeyPair key) {
                    byte[] body = concat(new byte[] {0x41}, Numeric.hexStringToByteArray(Keys.getAddress(key.getPublicKey())));
                    byte[] bytes = concat(body, Arrays.copyOf(Hash.sha256(Hash.sha256(body)), 4));
                    StringBuilder out = new StringBuilder();
                    for (BigInteger v = new BigInteger(1, bytes); v.signum() > 0; v = v.divide(BigInteger.valueOf(58))) {
                        out.append(BASE58.charAt(v.mod(BigInteger.valueOf(58)).intValue()));
                    }
                    return out.reverse().toString();
                }

                static byte[] concat(byte[]... parts) {
                    ByteArrayOutputStream out = new ByteArrayOutputStream();
                    for (byte[] part : parts) out.writeBytes(part);
                    return out.toByteArray();
                }
            }
      # x-codeSamples: end
      description: |
        Two ways in, one endpoint, each with its own body. Nothing is credited
        until this call: we check the one transaction it names, and credit it
        if it is what it should be. There is no watching of the treasury.

        | Deposit | **A · You broadcast it** | **B · We broadcast it (sponsored)** |
        |---|---|---|
        | Who pays the network | You | We do |
        | What you send | the `txId` of your transfer | the signed transfer, not yet broadcast |
        | What is credited | the amount | the amount less `sponsoredFee`, which may be `0.00` |
        | If our fee changed since you read it | does not apply | the fee now is taken, or `409` with nothing spent if you set `maxSponsoredFee` below it |

        **A · You broadcast it.** You sent the USDT to the treasury and paid the
        network for it yourself. A transaction already final is credited in
        the response (`CREDITED`); one in a block and not yet final reads
        `PENDING` and is credited once it is, within about a minute. One the
        network does not know yet is refused with `422`; claim it again a
        little later.

        ```json
        {
          "txId": "7c2d3e8f9a1b0c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5"
        }
        ```

        **B · We broadcast it (sponsored).** Offered while the account reads
        `deposit.sponsored.available: true`: on Nile now, and on mainnet later.
        Sign the transfer to the treasury and hand it over. We take the
        sponsored fee as it is when the request arrives. The response reads `SPONSORING`; follow it at
        `/{address}/transactions/{id}` until `CREDITED`, about a minute.

        ```json
        {
          "signedTransaction": "0a02c4e12208…"
        }
        ```

        The fee rarely changes, but it can change between your reading the
        account and your deposit arriving. If that matters to you, add
        `maxSponsoredFee`, usually the `deposit.sponsored.fee` you read: a
        higher fee is then refused with `409` and nothing is spent.

        ```json
        {
          "signedTransaction": "0a02c4e12208…",
          "maxSponsoredFee": "0.00"
        }
        ```

        Either way the transaction must be a USDT transfer from `{address}` to a
        treasury address of at least the account's `deposit.minimum`. A treasury
        address we have since replaced is still accepted, so a transfer to the
        address you read last time is never lost. A
        sponsored deposit is checked against the address's USDT balance before
        we spend anything; one that reverts after we paid, because the address
        was spent from in the meantime, credits nothing, and the account loses
        sponsored deposits.

        Idempotent on the transaction.
      parameters:
        - $ref: '#/components/parameters/Address'
        - $ref: '#/components/parameters/Issued'
        - $ref: '#/components/parameters/Signature'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DepositRequest' }
            examples:
              broadcast:
                summary: A · You broadcast it
                value: { txId: 7c2d3e8f9a1b0c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5 }
              sponsored:
                summary: B · We broadcast it (sponsored)
                value: { signedTransaction: 0a02c4e12208… }
              sponsoredCapped:
                summary: B · We broadcast it, refusing a higher fee
                value: { signedTransaction: 0a02c4e12208…, maxSponsoredFee: "0.00" }
      responses:
        '200':
          description: The deposit's state and, once credited, the credit after it
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Deposit' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: The sponsored fee is now higher than `maxSponsoredFee`, or a send from this address is in flight; nothing was spent
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                fee:
                  value: { code: SPONSORED_FEE_CHANGED, message: "The fee for a sponsored deposit is now 1.00 USDT; read the account and try again" }
                inFlight:
                  value: { code: CONFLICT, message: "A send from this address is still in flight" }
        '422':
          description: Cannot be credited
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                belowMinimum:
                  value: { code: DEPOSIT_BELOW_MINIMUM, message: "A deposit from this address is at least 20.00 USDT" }
                unclaimable:
                  value: { code: DEPOSIT_UNCLAIMABLE, message: "This transaction is not a USDT transfer from your address to the treasury" }
                notYet:
                  value: { code: DEPOSIT_UNCLAIMABLE, message: "The network does not know this transaction yet; claim it again in a minute" }
                balance:
                  value: { code: INSUFFICIENT_BALANCE, message: "The address holds less than the deposit" }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /{address}/quotes:
    post:
      tags: [Sends]
      summary: What a send would take from the credit now
      operationId: quote
      # x-codeSamples: generated by scripts/prepaid-samples.mjs from scripts/prepaid-samples/; edit there
      x-codeSamples:
        - lang: shell
          label: cURL
          source: |
            # POST /{address}/quotes: what a send would take from the credit
            # Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid
            # ACCOUNT is the address of the account; FROM_ADDRESS sends to TO_ADDRESS.
            # curl cannot sign. sign.mjs signs with ACCOUNT_KEY (hex) and prints the query to send:
            #   curl -sO https://crypto.dashing.ws/developers/prepaid/clients/sign.mjs && npm install tronweb@6
            BODY=$(printf '{"from":"%s","to":"%s","amount":"1.00"}' "$FROM_ADDRESS" "$TO_ADDRESS")
            QUERY=$(node sign.mjs POST "/prepaid/$ACCOUNT/quotes" "$BODY")
            curl -s -X POST "https://api.crypto.dashing.ws/prepaid/$ACCOUNT/quotes?$QUERY" \
              -H 'Content-Type: application/json' --data-raw "$BODY"
            # {"price":"1.50","currency":"USDT","recipientHoldsToken":true,"credit":{"before":"10.00","after":"8.50"}}
        - lang: javascript
          label: Node.js
          source: |
            // POST /{address}/quotes: what a send would take from the credit
            // Node 18 or later:  npm install tronweb@6
            import { createHash } from 'node:crypto';
            import { TronWeb } from 'tronweb';

            // Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
            const API = 'https://api.crypto.dashing.ws/prepaid';
            const NODE = 'https://api.trongrid.io';
            const ACCOUNT_KEY = process.env.ACCOUNT_KEY; // hex; the account's key signs every request
            const ACCOUNT = TronWeb.address.fromPrivateKey(ACCOUNT_KEY);
            const tronWeb = new TronWeb({ fullHost: NODE });

            // Every request is signed by the account's key. The signed message is five lines:
            //   Dashing Crypto prepaid v1
            //   METHOD
            //   the path as sent, network prefix included     /prepaid/T…/transfers
            //   the query without signature, encoded, sorted  issued=1790467200&limit=25
            //   SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
            // signed as a TRON message (TIP-191), which is what signMessageV2 does.
            async function call(method, path, { query = {}, body } = {}) {
              const url = new URL(`${API}/${ACCOUNT}${path}`);
              const encode = (s) =>
                encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
              const canonical = Object.entries({ ...query, issued: Math.floor(Date.now() / 1000) })
                .map(([name, value]) => [encode(name), encode(String(value))])
                .sort(([a, x], [b, y]) => (a === b ? (x < y ? -1 : 1) : a < b ? -1 : 1))
                .map(([name, value]) => `${name}=${value}`)
                .join('&');
              const text = body === undefined ? '' : JSON.stringify(body);
              const message = [
                'Dashing Crypto prepaid v1',
                method,
                url.pathname,
                canonical,
                createHash('sha256').update(text, 'utf8').digest('hex'),
              ].join('\n');
              const signature = (await tronWeb.trx.signMessageV2(message, ACCOUNT_KEY)).slice(2); // r‖s‖v, hex

              const response = await fetch(`${url}?${canonical}&signature=${signature}`, {
                method,
                headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
                body: body === undefined ? undefined : text, // the bytes that were hashed, unchanged
              });
              if (response.status === 204) return null; // a DELETE has no body
              const json = await response.json();
              if (!response.ok) throw new Error(`${response.status} ${json.code}: ${json.message}`);
              return json;
            }

            const quote = await call('POST', '/quotes', {
              body: { from: process.env.FROM_ADDRESS, to: process.env.TO_ADDRESS, amount: '1.00' },
            });
            console.log(quote.price, quote.recipientHoldsToken, quote.credit); // '1.50' true { before, after }
        - lang: python
          label: Python
          source: |
            # POST /{address}/quotes: what a send would take from the credit
            # Python 3.9 or later:  pip install coincurve pycryptodome base58 requests
            import hashlib
            import json
            import os
            import time
            from urllib.parse import quote, urlsplit

            import base58
            import requests
            from Crypto.Hash import keccak
            from coincurve import PrivateKey

            # Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
            API = "https://api.crypto.dashing.ws/prepaid"
            NODE = "https://api.trongrid.io"
            ACCOUNT_KEY = os.environ["ACCOUNT_KEY"]  # hex; the account's key signs every request


            def keccak256(data: bytes) -> bytes:
                return keccak.new(digest_bits=256, data=data).digest()


            def address_of(private_key_hex: str) -> str:
                """0x41 and the last 20 bytes of keccak(public key), in base58check."""
                public = PrivateKey(bytes.fromhex(private_key_hex)).public_key.format(compressed=False)[1:]
                return base58.b58encode_check(b"\x41" + keccak256(public)[-20:]).decode()


            ACCOUNT = address_of(ACCOUNT_KEY)


            def call(method: str, path: str, query: dict = None, body: dict = None) -> dict:
                """One request, signed by the account's key.

                The signed message is five lines:
                  Dashing Crypto prepaid v1
                  METHOD
                  the path as sent, network prefix included     /prepaid/T.../transfers
                  the query without signature, encoded, sorted  issued=1790467200&limit=25
                  SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
                signed as a TRON message (TIP-191): keccak-256 over "\\x19TRON Signed Message:\\n",
                the message's length in bytes and the message; secp256k1; r || s || v, v 27 or 28.
                """
                full_path = f"{urlsplit(API).path}/{ACCOUNT}{path}"
                params = dict(query or {}, issued=int(time.time()))
                encode = lambda value: quote(str(value), safe="-._~")  # RFC 3986
                canonical = "&".join(f"{k}={v}" for k, v in sorted((encode(k), encode(v)) for k, v in params.items()))
                data = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
                message = "\n".join([
                    "Dashing Crypto prepaid v1",
                    method,
                    full_path,
                    canonical,
                    hashlib.sha256(data).hexdigest(),
                ]).encode()

                digest = keccak256(b"\x19TRON Signed Message:\n" + str(len(message)).encode() + message)
                raw = PrivateKey(bytes.fromhex(ACCOUNT_KEY)).sign_recoverable(digest, hasher=None)
                signature = (raw[:64] + bytes([raw[64] + 27])).hex()

                origin = "{0.scheme}://{0.netloc}".format(urlsplit(API))
                response = requests.request(
                    method,
                    f"{origin}{full_path}?{canonical}&signature={signature}",
                    data=data if body is not None else None,  # the bytes that were hashed, unchanged
                    headers={"Content-Type": "application/json"} if body is not None else {},
                    timeout=30,
                )
                if response.status_code == 204:  # a DELETE has no body
                    return None
                result = response.json()
                if not response.ok:
                    raise RuntimeError(f"{response.status_code} {result['code']}: {result['message']}")
                return result


            quote = call("POST", "/quotes", body={
                "from": os.environ["FROM_ADDRESS"],
                "to": os.environ["TO_ADDRESS"],
                "amount": "1.00",
            })
            print(quote["price"], quote["recipientHoldsToken"], quote["credit"])  # 1.50 True {'before': ..., 'after': ...}
        - lang: java
          label: Java
          source: |
            // POST /{address}/quotes: what a send would take from the credit
            // Java 17 or later, with org.web3j:crypto:5.0.0 and com.fasterxml.jackson.core:jackson-databind
            import com.fasterxml.jackson.databind.JsonNode;
            import com.fasterxml.jackson.databind.ObjectMapper;
            import java.io.ByteArrayOutputStream;
            import java.math.BigDecimal;
            import java.math.BigInteger;
            import java.net.URI;
            import java.net.http.HttpClient;
            import java.net.http.HttpRequest;
            import java.net.http.HttpResponse;
            import java.nio.charset.StandardCharsets;
            import java.util.Arrays;
            import java.util.Comparator;
            import java.util.Map;
            import java.util.stream.Collectors;
            import org.web3j.crypto.ECKeyPair;
            import org.web3j.crypto.Hash;
            import org.web3j.crypto.Keys;
            import org.web3j.crypto.Sign;
            import org.web3j.utils.Numeric;

            public class Prepaid {

                // Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
                static final String API = "https://api.crypto.dashing.ws/prepaid";
                static final String NODE = "https://api.trongrid.io";
                static final ECKeyPair ACCOUNT_KEY = key(System.getenv("ACCOUNT_KEY")); // signs every request
                static final String ACCOUNT = addressOf(ACCOUNT_KEY);
                static final ObjectMapper JSON = new ObjectMapper();
                static final HttpClient HTTP = HttpClient.newHttpClient();

                public static void main(String[] args) throws Exception {
            JsonNode quote = call("POST", "/quotes", Map.of(), Map.of(
                    "from", System.getenv("FROM_ADDRESS"),
                    "to", System.getenv("TO_ADDRESS"),
                    "amount", "1.00"));
            System.out.println(quote.path("price").asText() + " " + quote.path("credit")); // 1.50 {"before":…,"after":…}
                }

                /**
                 * One request, signed by the account's key. The signed message is five lines:
                 *
                 * <pre>
                 *   Dashing Crypto prepaid v1
                 *   METHOD
                 *   the path as sent, network prefix included     /prepaid/T…/transfers
                 *   the query without signature, encoded, sorted  issued=1790467200&amp;limit=25
                 *   SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
                 * </pre>
                 *
                 * signed as a TRON message (TIP-191): keccak-256 over "\x19TRON Signed Message:\n", the
                 * message's length in bytes and the message; secp256k1; r‖s‖v with v 27 or 28.
                 */
                static JsonNode call(String method, String path, Map<String, String> query, Object body) throws Exception {
                    URI api = URI.create(API);
                    String fullPath = api.getPath() + "/" + ACCOUNT + path;

                    Map<String, String> params = new java.util.HashMap<>(query);
                    params.put("issued", Long.toString(System.currentTimeMillis() / 1000));
                    String canonical = params.entrySet().stream()
                            .map(e -> new String[] {encode(e.getKey()), encode(e.getValue())})
                            .sorted(Comparator.<String[], String>comparing(p -> p[0]).thenComparing(p -> p[1]))
                            .map(p -> p[0] + "=" + p[1])
                            .collect(Collectors.joining("&"));

                    byte[] bytes = body == null ? new byte[0] : JSON.writeValueAsBytes(body);
                    byte[] message = String.join("\n",
                            "Dashing Crypto prepaid v1",
                            method,
                            fullPath,
                            canonical,
                            Numeric.toHexStringNoPrefix(Hash.sha256(bytes))).getBytes(StandardCharsets.UTF_8);
                    byte[] digest = Hash.sha3(concat( // keccak-256
                            ("\u0019TRON Signed Message:\n" + message.length).getBytes(StandardCharsets.UTF_8), message));
                    Sign.SignatureData sig = Sign.signMessage(digest, ACCOUNT_KEY, false); // v is 27 or 28
                    String signature = Numeric.toHexStringNoPrefix(concat(sig.getR(), sig.getS(), sig.getV()));

                    HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(
                            api.getScheme() + "://" + api.getAuthority() + fullPath + "?" + canonical + "&signature=" + signature));
                    if (body == null) {
                        request.method(method, HttpRequest.BodyPublishers.noBody());
                    } else { // the bytes that were hashed, unchanged
                        request.header("Content-Type", "application/json").method(method, HttpRequest.BodyPublishers.ofByteArray(bytes));
                    }
                    HttpResponse<String> response = HTTP.send(request.build(), HttpResponse.BodyHandlers.ofString());
                    if (response.statusCode() == 204) {
                        return null; // a DELETE has no body
                    }
                    JsonNode json = JSON.readTree(response.body());
                    if (response.statusCode() >= 400) {
                        throw new IllegalStateException(
                                response.statusCode() + " " + json.path("code").asText() + ": " + json.path("message").asText());
                    }
                    return json;
                }

                /** RFC 3986: A–Z a–z 0–9 - . _ ~ stay; every other byte is %XX in upper-case hex. */
                static String encode(String value) {
                    StringBuilder out = new StringBuilder();
                    for (byte b : value.getBytes(StandardCharsets.UTF_8)) {
                        int c = b & 0xFF;
                        boolean unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
                                || c == '-' || c == '.' || c == '_' || c == '~';
                        out.append(unreserved ? String.valueOf((char) c) : String.format("%%%02X", c));
                    }
                    return out.toString();
                }

                static final String BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";

                static ECKeyPair key(String hex) {
                    return ECKeyPair.create(new BigInteger(hex, 16));
                }

                /** 0x41 and the last 20 bytes of keccak(public key), in base58check. */
                static String addressOf(ECKeyPair key) {
                    byte[] body = concat(new byte[] {0x41}, Numeric.hexStringToByteArray(Keys.getAddress(key.getPublicKey())));
                    byte[] bytes = concat(body, Arrays.copyOf(Hash.sha256(Hash.sha256(body)), 4));
                    StringBuilder out = new StringBuilder();
                    for (BigInteger v = new BigInteger(1, bytes); v.signum() > 0; v = v.divide(BigInteger.valueOf(58))) {
                        out.append(BASE58.charAt(v.mod(BigInteger.valueOf(58)).intValue()));
                    }
                    return out.reverse().toString();
                }

                static byte[] concat(byte[]... parts) {
                    ByteArrayOutputStream out = new ByteArrayOutputStream();
                    for (byte[] part : parts) out.writeBytes(part);
                    return out.toByteArray();
                }
            }
      # x-codeSamples: end
      description: |
        The price as of this response, measured against the recipient's state
        and the resource market. Nothing is charged, and nothing is held: the
        send is priced again when it arrives. The price rarely moves in
        between, but it can; to refuse a higher one, send the price you were
        shown as `maxPrice` with the send.

        `from` must hold at least `amount`, and a recipient that is a treasury
        address or the sender is refused.
      parameters:
        - $ref: '#/components/parameters/Address'
        - $ref: '#/components/parameters/Issued'
        - $ref: '#/components/parameters/Signature'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/QuoteRequest' }
      responses:
        '200':
          description: The price
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Quote' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422':
          description: Refused
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                credit:
                  value: { code: INSUFFICIENT_CREDIT, message: "The credit does not cover the price of this send" }
                balance:
                  value: { code: INSUFFICIENT_BALANCE, message: "The sending address holds less than the amount" }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /{address}/transfers:
    post:
      tags: [Sends]
      summary: Send
      operationId: transfer
      # x-codeSamples: generated by scripts/prepaid-samples.mjs from scripts/prepaid-samples/; edit there
      x-codeSamples:
        - lang: shell
          label: cURL
          source: |
            # POST /{address}/transfers: send
            # Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid
            # ACCOUNT is the address of the account; FROM_ADDRESS sends to TO_ADDRESS.
            # SIGNED_TRANSACTION is the USDT transfer from your user, signed by their key, as hex: curl
            # cannot build one, and the Node.js, Python and Java tabs show how.
            # curl cannot sign. sign.mjs signs with ACCOUNT_KEY (hex) and prints the query to send:
            #   curl -sO https://crypto.dashing.ws/developers/prepaid/clients/sign.mjs && npm install tronweb@6
            BODY=$(printf '{"from":"%s","to":"%s","amount":"1.00","signedTransaction":"%s"}' \
              "$FROM_ADDRESS" "$TO_ADDRESS" "$SIGNED_TRANSACTION")
            QUERY=$(node sign.mjs POST "/prepaid/$ACCOUNT/transfers" "$BODY")
            curl -s -X POST "https://api.crypto.dashing.ws/prepaid/$ACCOUNT/transfers?$QUERY" \
              -H 'Content-Type: application/json' --data-raw "$BODY"
            # {"id":"…","kind":"SEND","state":"SUBMITTED",…}: follow it at /transactions/{id}
        - lang: javascript
          label: Node.js
          source: |
            // POST /{address}/transfers: send
            // Node 18 or later:  npm install tronweb@6
            import { createHash } from 'node:crypto';
            import { TronWeb } from 'tronweb';

            // Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
            const API = 'https://api.crypto.dashing.ws/prepaid';
            const NODE = 'https://api.trongrid.io';
            const ACCOUNT_KEY = process.env.ACCOUNT_KEY; // hex; the account's key signs every request
            const ACCOUNT = TronWeb.address.fromPrivateKey(ACCOUNT_KEY);
            const tronWeb = new TronWeb({ fullHost: NODE });

            // Every request is signed by the account's key. The signed message is five lines:
            //   Dashing Crypto prepaid v1
            //   METHOD
            //   the path as sent, network prefix included     /prepaid/T…/transfers
            //   the query without signature, encoded, sorted  issued=1790467200&limit=25
            //   SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
            // signed as a TRON message (TIP-191), which is what signMessageV2 does.
            async function call(method, path, { query = {}, body } = {}) {
              const url = new URL(`${API}/${ACCOUNT}${path}`);
              const encode = (s) =>
                encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
              const canonical = Object.entries({ ...query, issued: Math.floor(Date.now() / 1000) })
                .map(([name, value]) => [encode(name), encode(String(value))])
                .sort(([a, x], [b, y]) => (a === b ? (x < y ? -1 : 1) : a < b ? -1 : 1))
                .map(([name, value]) => `${name}=${value}`)
                .join('&');
              const text = body === undefined ? '' : JSON.stringify(body);
              const message = [
                'Dashing Crypto prepaid v1',
                method,
                url.pathname,
                canonical,
                createHash('sha256').update(text, 'utf8').digest('hex'),
              ].join('\n');
              const signature = (await tronWeb.trx.signMessageV2(message, ACCOUNT_KEY)).slice(2); // r‖s‖v, hex

              const response = await fetch(`${url}?${canonical}&signature=${signature}`, {
                method,
                headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
                body: body === undefined ? undefined : text, // the bytes that were hashed, unchanged
              });
              if (response.status === 204) return null; // a DELETE has no body
              const json = await response.json();
              if (!response.ok) throw new Error(`${response.status} ${json.code}: ${json.message}`);
              return json;
            }

            // A USDT transfer the sender signs with their own key: a TRC20 transfer on the USDT contract,
            // fee_limit 20 TRX, five minutes to expiry (a node stamps one minute; the API wants two to ten).
            // Returns the whole signed Transaction as hex, which is what the API takes.
            async function signedUsdtTransfer(senderKey, to, amount) {
              const USDT = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; // Mainnet's; Nile's is TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf
              const from = TronWeb.address.fromPrivateKey(senderKey);
              const [whole, fraction = ''] = amount.split('.');
              const units = (BigInt(whole) * 1_000_000n + BigInt(fraction.padEnd(6, '0'))).toString(); // six decimals
              const { transaction } = await tronWeb.transactionBuilder.triggerSmartContract(
                USDT,
                'transfer(address,uint256)',
                { feeLimit: 20_000_000 },
                [
                  { type: 'address', value: to },
                  { type: 'uint256', value: units },
                ],
                from,
              );
              const extended = await tronWeb.transactionBuilder.extendExpiration(transaction, 240); // 60 s + 240 s
              const signed = await tronWeb.trx.sign(extended, senderKey);

              // Transaction { raw_data = 1; signature = 2 }, as protobuf, as hex.
              const varint = (n) => {
                let out = '';
                for (; n > 0x7f; n >>>= 7) out += ((n & 0x7f) | 0x80).toString(16).padStart(2, '0');
                return out + n.toString(16).padStart(2, '0');
              };
              const field = (tag, hex) => tag + varint(hex.length / 2) + hex;
              return {
                txID: signed.txID,
                hex: field('0a', signed.raw_data_hex) + signed.signature.map((s) => field('12', s)).join(''),
              };
            }

            // Your user's key signs the USDT transfer; the account's key signs the request.
            const SENDER_KEY = process.env.SENDER_KEY;
            const from = TronWeb.address.fromPrivateKey(SENDER_KEY);
            const to = process.env.TO_ADDRESS;
            const amount = '1.00';

            const { price } = await call('POST', '/quotes', { body: { from, to, amount } });
            const signed = await signedUsdtTransfer(SENDER_KEY, to, amount);
            const send = await call('POST', '/transfers', {
              body: { from, to, amount, signedTransaction: signed.hex, maxPrice: price },
            });
            console.log(send.id, send.state, send.txId); // SUBMITTED; follow it at /transactions/{id}
        - lang: python
          label: Python
          source: |
            # POST /{address}/transfers: send
            # Python 3.9 or later:  pip install coincurve pycryptodome base58 requests
            import hashlib
            import json
            import os
            import time
            from urllib.parse import quote, urlsplit

            import base58
            import requests
            from Crypto.Hash import keccak
            from coincurve import PrivateKey

            # Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
            API = "https://api.crypto.dashing.ws/prepaid"
            NODE = "https://api.trongrid.io"
            ACCOUNT_KEY = os.environ["ACCOUNT_KEY"]  # hex; the account's key signs every request


            def keccak256(data: bytes) -> bytes:
                return keccak.new(digest_bits=256, data=data).digest()


            def address_of(private_key_hex: str) -> str:
                """0x41 and the last 20 bytes of keccak(public key), in base58check."""
                public = PrivateKey(bytes.fromhex(private_key_hex)).public_key.format(compressed=False)[1:]
                return base58.b58encode_check(b"\x41" + keccak256(public)[-20:]).decode()


            ACCOUNT = address_of(ACCOUNT_KEY)


            def call(method: str, path: str, query: dict = None, body: dict = None) -> dict:
                """One request, signed by the account's key.

                The signed message is five lines:
                  Dashing Crypto prepaid v1
                  METHOD
                  the path as sent, network prefix included     /prepaid/T.../transfers
                  the query without signature, encoded, sorted  issued=1790467200&limit=25
                  SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
                signed as a TRON message (TIP-191): keccak-256 over "\\x19TRON Signed Message:\\n",
                the message's length in bytes and the message; secp256k1; r || s || v, v 27 or 28.
                """
                full_path = f"{urlsplit(API).path}/{ACCOUNT}{path}"
                params = dict(query or {}, issued=int(time.time()))
                encode = lambda value: quote(str(value), safe="-._~")  # RFC 3986
                canonical = "&".join(f"{k}={v}" for k, v in sorted((encode(k), encode(v)) for k, v in params.items()))
                data = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
                message = "\n".join([
                    "Dashing Crypto prepaid v1",
                    method,
                    full_path,
                    canonical,
                    hashlib.sha256(data).hexdigest(),
                ]).encode()

                digest = keccak256(b"\x19TRON Signed Message:\n" + str(len(message)).encode() + message)
                raw = PrivateKey(bytes.fromhex(ACCOUNT_KEY)).sign_recoverable(digest, hasher=None)
                signature = (raw[:64] + bytes([raw[64] + 27])).hex()

                origin = "{0.scheme}://{0.netloc}".format(urlsplit(API))
                response = requests.request(
                    method,
                    f"{origin}{full_path}?{canonical}&signature={signature}",
                    data=data if body is not None else None,  # the bytes that were hashed, unchanged
                    headers={"Content-Type": "application/json"} if body is not None else {},
                    timeout=30,
                )
                if response.status_code == 204:  # a DELETE has no body
                    return None
                result = response.json()
                if not response.ok:
                    raise RuntimeError(f"{response.status_code} {result['code']}: {result['message']}")
                return result


            def signed_usdt_transfer(sender_key: str, to: str, amount: str) -> dict:
                """A USDT transfer the sender signs with their own key.

                A TRC20 transfer on the USDT contract, fee_limit 20 TRX, five minutes to expiry (a node stamps
                one minute; the API wants two to ten). Returns the whole signed Transaction as hex.
                """
                usdt = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"  # Mainnet's; Nile's is TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf
                whole, _, fraction = amount.partition(".")
                units = int(whole) * 1_000_000 + int(fraction.ljust(6, "0") or 0)
                parameter = base58.b58decode_check(to)[1:].rjust(32, b"\0").hex() + units.to_bytes(32, "big").hex()
                built = requests.post(f"{NODE}/wallet/triggersmartcontract", json={
                    "owner_address": address_of(sender_key),
                    "contract_address": usdt,
                    "function_selector": "transfer(address,uint256)",
                    "parameter": parameter,
                    "fee_limit": 20_000_000,
                    "call_value": 0,
                    "visible": True,
                }, timeout=30).json()["transaction"]

                # raw_data field 8 is the expiry in milliseconds; the transaction id is SHA-256 of raw_data.
                raw = with_expiration(bytes.fromhex(built["raw_data_hex"]), int(time.time() * 1000) + 5 * 60 * 1000)
                tx_id = hashlib.sha256(raw).digest()
                signature = PrivateKey(bytes.fromhex(sender_key)).sign_recoverable(tx_id, hasher=None)

                # Transaction { raw_data = 1; signature = 2 }, as protobuf.
                signed = b"\x0a" + varint(len(raw)) + raw + b"\x12" + varint(len(signature)) + signature
                return {"txID": tx_id.hex(), "hex": signed.hex()}


            def with_expiration(raw: bytes, expiration_ms: int) -> bytes:
                """raw_data with field 8 replaced; every other field is copied as it is."""
                out, at = bytearray(), 0
                while at < len(raw):
                    start = at
                    tag, at = read_varint(raw, at)
                    wire = tag & 7
                    if wire == 0:
                        _, at = read_varint(raw, at)
                    elif wire == 2:
                        length, at = read_varint(raw, at)
                        at += length
                    else:
                        at += 8 if wire == 1 else 4
                    out += varint(tag) + varint(expiration_ms) if (tag >> 3, wire) == (8, 0) else raw[start:at]
                return bytes(out)


            def varint(n: int) -> bytes:
                out = bytearray()
                while n > 0x7F:
                    out.append((n & 0x7F) | 0x80)
                    n >>= 7
                return bytes(out + bytes([n]))


            def read_varint(data: bytes, at: int):
                value = shift = 0
                while True:
                    byte = data[at]
                    at += 1
                    value |= (byte & 0x7F) << shift
                    if not byte & 0x80:
                        return value, at
                    shift += 7


            # Your user's key signs the USDT transfer; the account's key signs the request.
            SENDER_KEY = os.environ["SENDER_KEY"]
            sender = address_of(SENDER_KEY)
            to = os.environ["TO_ADDRESS"]
            amount = "1.00"

            price = call("POST", "/quotes", body={"from": sender, "to": to, "amount": amount})["price"]
            signed = signed_usdt_transfer(SENDER_KEY, to, amount)
            send = call("POST", "/transfers", body={
                "from": sender,
                "to": to,
                "amount": amount,
                "signedTransaction": signed["hex"],
                "maxPrice": price,
            })
            print(send["id"], send["state"], send["txId"])  # SUBMITTED; follow it at /transactions/{id}
        - lang: java
          label: Java
          source: |
            // POST /{address}/transfers: send
            // Java 17 or later, with org.web3j:crypto:5.0.0 and com.fasterxml.jackson.core:jackson-databind
            import com.fasterxml.jackson.databind.JsonNode;
            import com.fasterxml.jackson.databind.ObjectMapper;
            import java.io.ByteArrayOutputStream;
            import java.math.BigDecimal;
            import java.math.BigInteger;
            import java.net.URI;
            import java.net.http.HttpClient;
            import java.net.http.HttpRequest;
            import java.net.http.HttpResponse;
            import java.nio.charset.StandardCharsets;
            import java.util.Arrays;
            import java.util.Comparator;
            import java.util.Map;
            import java.util.stream.Collectors;
            import org.web3j.crypto.ECKeyPair;
            import org.web3j.crypto.Hash;
            import org.web3j.crypto.Keys;
            import org.web3j.crypto.Sign;
            import org.web3j.utils.Numeric;

            public class Prepaid {

                // Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
                static final String API = "https://api.crypto.dashing.ws/prepaid";
                static final String NODE = "https://api.trongrid.io";
                static final ECKeyPair ACCOUNT_KEY = key(System.getenv("ACCOUNT_KEY")); // signs every request
                static final String ACCOUNT = addressOf(ACCOUNT_KEY);
                static final ObjectMapper JSON = new ObjectMapper();
                static final HttpClient HTTP = HttpClient.newHttpClient();

                public static void main(String[] args) throws Exception {
            // Your user's key signs the USDT transfer; the account's key signs the request.
            ECKeyPair senderKey = key(System.getenv("SENDER_KEY"));
            String from = addressOf(senderKey);
            String to = System.getenv("TO_ADDRESS");
            String amount = "1.00";

            String price = call("POST", "/quotes", Map.of(), Map.of("from", from, "to", to, "amount", amount))
                    .path("price").asText();
            String[] signed = signedUsdtTransfer(senderKey, to, amount);
            JsonNode send = call("POST", "/transfers", Map.of(), Map.of(
                    "from", from,
                    "to", to,
                    "amount", amount,
                    "signedTransaction", signed[1],
                    "maxPrice", price));
            System.out.println(send.path("id").asText() + " " + send.path("state").asText()); // SUBMITTED
                }

                /**
                 * One request, signed by the account's key. The signed message is five lines:
                 *
                 * <pre>
                 *   Dashing Crypto prepaid v1
                 *   METHOD
                 *   the path as sent, network prefix included     /prepaid/T…/transfers
                 *   the query without signature, encoded, sorted  issued=1790467200&amp;limit=25
                 *   SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
                 * </pre>
                 *
                 * signed as a TRON message (TIP-191): keccak-256 over "\x19TRON Signed Message:\n", the
                 * message's length in bytes and the message; secp256k1; r‖s‖v with v 27 or 28.
                 */
                static JsonNode call(String method, String path, Map<String, String> query, Object body) throws Exception {
                    URI api = URI.create(API);
                    String fullPath = api.getPath() + "/" + ACCOUNT + path;

                    Map<String, String> params = new java.util.HashMap<>(query);
                    params.put("issued", Long.toString(System.currentTimeMillis() / 1000));
                    String canonical = params.entrySet().stream()
                            .map(e -> new String[] {encode(e.getKey()), encode(e.getValue())})
                            .sorted(Comparator.<String[], String>comparing(p -> p[0]).thenComparing(p -> p[1]))
                            .map(p -> p[0] + "=" + p[1])
                            .collect(Collectors.joining("&"));

                    byte[] bytes = body == null ? new byte[0] : JSON.writeValueAsBytes(body);
                    byte[] message = String.join("\n",
                            "Dashing Crypto prepaid v1",
                            method,
                            fullPath,
                            canonical,
                            Numeric.toHexStringNoPrefix(Hash.sha256(bytes))).getBytes(StandardCharsets.UTF_8);
                    byte[] digest = Hash.sha3(concat( // keccak-256
                            ("\u0019TRON Signed Message:\n" + message.length).getBytes(StandardCharsets.UTF_8), message));
                    Sign.SignatureData sig = Sign.signMessage(digest, ACCOUNT_KEY, false); // v is 27 or 28
                    String signature = Numeric.toHexStringNoPrefix(concat(sig.getR(), sig.getS(), sig.getV()));

                    HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(
                            api.getScheme() + "://" + api.getAuthority() + fullPath + "?" + canonical + "&signature=" + signature));
                    if (body == null) {
                        request.method(method, HttpRequest.BodyPublishers.noBody());
                    } else { // the bytes that were hashed, unchanged
                        request.header("Content-Type", "application/json").method(method, HttpRequest.BodyPublishers.ofByteArray(bytes));
                    }
                    HttpResponse<String> response = HTTP.send(request.build(), HttpResponse.BodyHandlers.ofString());
                    if (response.statusCode() == 204) {
                        return null; // a DELETE has no body
                    }
                    JsonNode json = JSON.readTree(response.body());
                    if (response.statusCode() >= 400) {
                        throw new IllegalStateException(
                                response.statusCode() + " " + json.path("code").asText() + ": " + json.path("message").asText());
                    }
                    return json;
                }

                /** RFC 3986: A–Z a–z 0–9 - . _ ~ stay; every other byte is %XX in upper-case hex. */
                static String encode(String value) {
                    StringBuilder out = new StringBuilder();
                    for (byte b : value.getBytes(StandardCharsets.UTF_8)) {
                        int c = b & 0xFF;
                        boolean unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
                                || c == '-' || c == '.' || c == '_' || c == '~';
                        out.append(unreserved ? String.valueOf((char) c) : String.format("%%%02X", c));
                    }
                    return out.toString();
                }

                /**
                 * A USDT transfer the sender signs with their own key: a TRC20 transfer on the USDT contract,
                 * fee_limit 20 TRX, five minutes to expiry (a node stamps one minute; the API wants two to ten).
                 * Returns {txID, the whole signed Transaction as hex}; the hex is what the API takes.
                 */
                static String[] signedUsdtTransfer(ECKeyPair sender, String to, String amount) throws Exception {
                    String usdt = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; // Mainnet's; Nile's is TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf
                    long units = new BigDecimal(amount).movePointRight(6).longValueExact();
                    byte[] recipient = Arrays.copyOfRange(base58Decode(to), 1, 21);
                    String parameter = Numeric.toHexStringNoPrefixZeroPadded(new BigInteger(1, recipient), 64)
                            + Numeric.toHexStringNoPrefixZeroPadded(BigInteger.valueOf(units), 64);
                    String build = JSON.writeValueAsString(Map.of(
                            "owner_address", addressOf(sender),
                            "contract_address", usdt,
                            "function_selector", "transfer(address,uint256)",
                            "parameter", parameter,
                            "fee_limit", 20_000_000,
                            "call_value", 0,
                            "visible", true));
                    JsonNode built = JSON.readTree(HTTP.send(
                            HttpRequest.newBuilder(URI.create(NODE + "/wallet/triggersmartcontract"))
                                    .header("Content-Type", "application/json")
                                    .POST(HttpRequest.BodyPublishers.ofString(build)).build(),
                            HttpResponse.BodyHandlers.ofString()).body()).path("transaction");

                    // raw_data field 8 is the expiry in milliseconds; the transaction id is SHA-256 of raw_data.
                    byte[] raw = withExpiration(Numeric.hexStringToByteArray(built.path("raw_data_hex").asText()),
                            System.currentTimeMillis() + 5 * 60 * 1000);
                    byte[] txId = Hash.sha256(raw);
                    Sign.SignatureData sig = Sign.signMessage(txId, sender, false);
                    byte[] signature = concat(sig.getR(), sig.getS(), sig.getV());

                    // Transaction { raw_data = 1; signature = 2 }, as protobuf.
                    byte[] signed = concat(new byte[] {0x0a}, varint(raw.length), raw, new byte[] {0x12}, varint(65), signature);
                    return new String[] {Numeric.toHexStringNoPrefix(txId), Numeric.toHexStringNoPrefix(signed)};
                }

                /** raw_data with field 8 replaced; every other field is copied as it is. */
                static byte[] withExpiration(byte[] raw, long expirationMillis) {
                    ByteArrayOutputStream out = new ByteArrayOutputStream();
                    int[] at = {0};
                    while (at[0] < raw.length) {
                        int start = at[0];
                        long tag = readVarint(raw, at);
                        int wire = (int) (tag & 7);
                        if (wire == 0) {
                            readVarint(raw, at);
                        } else if (wire == 2) {
                            int length = (int) readVarint(raw, at); // read first: `at[0] += readVarint(…)` loses the length's own bytes
                            at[0] += length;
                        } else {
                            at[0] += wire == 1 ? 8 : 4;
                        }
                        if (tag >>> 3 == 8 && wire == 0) out.writeBytes(concat(varint(tag), varint(expirationMillis)));
                        else out.write(raw, start, at[0] - start);
                    }
                    return out.toByteArray();
                }

                static byte[] base58Decode(String text) {
                    BigInteger value = BigInteger.ZERO;
                    for (char c : text.toCharArray()) {
                        value = value.multiply(BigInteger.valueOf(58)).add(BigInteger.valueOf(BASE58.indexOf(c)));
                    }
                    return Numeric.toBytesPadded(value, 25); // 0x41, 20 bytes, 4 bytes of checksum
                }

                static byte[] varint(long value) {
                    ByteArrayOutputStream out = new ByteArrayOutputStream();
                    for (; (value & ~0x7FL) != 0; value >>>= 7) out.write((int) ((value & 0x7F) | 0x80));
                    out.write((int) value);
                    return out.toByteArray();
                }

                static long readVarint(byte[] bytes, int[] at) {
                    long value = 0;
                    for (int shift = 0; ; shift += 7) {
                        byte b = bytes[at[0]++];
                        value |= (long) (b & 0x7F) << shift;
                        if ((b & 0x80) == 0) return value;
                    }
                }

                static final String BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";

                static ECKeyPair key(String hex) {
                    return ECKeyPair.create(new BigInteger(hex, 16));
                }

                /** 0x41 and the last 20 bytes of keccak(public key), in base58check. */
                static String addressOf(ECKeyPair key) {
                    byte[] body = concat(new byte[] {0x41}, Numeric.hexStringToByteArray(Keys.getAddress(key.getPublicKey())));
                    byte[] bytes = concat(body, Arrays.copyOf(Hash.sha256(Hash.sha256(body)), 4));
                    StringBuilder out = new StringBuilder();
                    for (BigInteger v = new BigInteger(1, bytes); v.signum() > 0; v = v.divide(BigInteger.valueOf(58))) {
                        out.append(BASE58.charAt(v.mod(BigInteger.valueOf(58)).intValue()));
                    }
                    return out.reverse().toString();
                }

                static byte[] concat(byte[]... parts) {
                    ByteArrayOutputStream out = new ByteArrayOutputStream();
                    for (byte[] part : parts) out.writeBytes(part);
                    return out.toByteArray();
                }
            }
      # x-codeSamples: end
      description: |
        One request. Your user signs a USDT transfer from `from` to `to` for
        `amount` with their own key; you hand it over as `signedTransaction`
        with `from`, `to` and `amount` stated beside it, so what you meant and
        what was signed can be checked against each other. The request's own
        signature, from the account address, is what allows the credit to be
        charged.

        The transaction: a TRC20 `transfer` on the USDT contract, `fee_limit`
        of 20 TRX (`20000000` sun), and an expiration two to ten minutes away
        when it arrives. Sign it with five: a node stamps one minute when it
        builds a transaction, which is refused, so extend it before signing
        (TronWeb: `transactionBuilder.extendExpiration`; otherwise rewrite
        field 8 of `raw_data`, as the Python and Java samples do). It is
        refused unsigned, signed by anyone but `from`, naming a different
        recipient, amount or contract than stated, or with a `fee_limit` that
        does not cover its energy at the day's price.

        In this order, each step refusing at no cost if it fails:

        1. both signatures;
        2. the price, measured now — the credit must cover it, and it must not
           be above `maxPrice` if you sent one;
        3. `from` holds at least `amount`; one open send per `from` at a time;
        4. our own side: reseller balance, energy price under the fee limit,
           the daily cap;
        5. the credit is debited, conditionally, so two sends cannot both take
           the last of it;
        6. energy and bandwidth are put on `from`, the transaction is broadcast
           and followed to a block.

        The response is immediate; follow the send at
        `/{address}/transactions/{id}`, usually `CONFIRMED` within twenty
        seconds. A revert caused by `from` being spent from stays charged, and
        `from` is refused further sends. A failure on our side before any energy
        reaches `from` returns the charge, as a `RETURN` line beside the failed
        send; once energy is on `from`, the charge stands however the send ends.

        Idempotent on the transaction: handing over the same one again returns
        the same send. The same transaction from another account is refused
        with `409`.
      parameters:
        - $ref: '#/components/parameters/Address'
        - $ref: '#/components/parameters/Issued'
        - $ref: '#/components/parameters/Signature'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TransferRequest' }
      responses:
        '200':
          description: Accepted, and where it has got to
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Transaction' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: A send from this address is in flight, or the price is above `maxPrice`; nothing was charged
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                inFlight:
                  value: { code: CONFLICT, message: "A send from this address is still in flight" }
                price:
                  value: { code: PRICE_CHANGED, message: "This send now costs 2.00 USDT; ask the price again" }
        '422':
          description: Refused before anything was charged
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                credit:
                  value: { code: INSUFFICIENT_CREDIT, message: "The credit does not cover the price of this send" }
                balance:
                  value: { code: INSUFFICIENT_BALANCE, message: "The sending address holds less than the amount" }
                transaction:
                  value: { code: TRANSACTION_REJECTED, message: "The signed transaction does not match the stated send" }
                expired:
                  value: { code: TRANSACTION_EXPIRED, message: "The transaction's expiry is too near or too far; it must be two to ten minutes away. Sign it again with five" }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /{address}/transactions:
    get:
      tags: [History]
      summary: Everything that moved the credit
      operationId: listTransactions
      # x-codeSamples: generated by scripts/prepaid-samples.mjs from scripts/prepaid-samples/; edit there
      x-codeSamples:
        - lang: shell
          label: cURL
          source: |
            # GET /{address}/transactions: everything that moved the credit
            # Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid
            # ACCOUNT is the address of the account, READ_TOKEN a token from POST /{address}/tokens.
            curl -s "https://api.crypto.dashing.ws/prepaid/$ACCOUNT/transactions?limit=25" -H "Authorization: Bearer $READ_TOKEN"
            # {"items":[{"id":"…","kind":"DEPOSIT","amount":"20.00","state":"CREDITED",…}],"nextCursor":"eyJr…"}

            # The next page: the same limit, and nextCursor as it came. null means there is no next page.
            curl -s "https://api.crypto.dashing.ws/prepaid/$ACCOUNT/transactions?limit=25&cursor=$NEXT_CURSOR" -H "Authorization: Bearer $READ_TOKEN"

            # Signed by the key instead of a token, the other parameters are signed with it:
            #   QUERY=$(node sign.mjs GET "/prepaid/$ACCOUNT/transactions" '' "limit=25")
            #   curl -s "https://api.crypto.dashing.ws/prepaid/$ACCOUNT/transactions?$QUERY"
        - lang: javascript
          label: Node.js
          source: |
            // GET /{address}/transactions: everything that moved the credit
            // Node 18 or later:  npm install tronweb@6
            import { createHash } from 'node:crypto';
            import { TronWeb } from 'tronweb';

            // Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
            const API = 'https://api.crypto.dashing.ws/prepaid';
            const NODE = 'https://api.trongrid.io';
            const ACCOUNT_KEY = process.env.ACCOUNT_KEY; // hex; the account's key signs every request
            const ACCOUNT = TronWeb.address.fromPrivateKey(ACCOUNT_KEY);
            const tronWeb = new TronWeb({ fullHost: NODE });

            // Every request is signed by the account's key. The signed message is five lines:
            //   Dashing Crypto prepaid v1
            //   METHOD
            //   the path as sent, network prefix included     /prepaid/T…/transfers
            //   the query without signature, encoded, sorted  issued=1790467200&limit=25
            //   SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
            // signed as a TRON message (TIP-191), which is what signMessageV2 does.
            async function call(method, path, { query = {}, body } = {}) {
              const url = new URL(`${API}/${ACCOUNT}${path}`);
              const encode = (s) =>
                encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
              const canonical = Object.entries({ ...query, issued: Math.floor(Date.now() / 1000) })
                .map(([name, value]) => [encode(name), encode(String(value))])
                .sort(([a, x], [b, y]) => (a === b ? (x < y ? -1 : 1) : a < b ? -1 : 1))
                .map(([name, value]) => `${name}=${value}`)
                .join('&');
              const text = body === undefined ? '' : JSON.stringify(body);
              const message = [
                'Dashing Crypto prepaid v1',
                method,
                url.pathname,
                canonical,
                createHash('sha256').update(text, 'utf8').digest('hex'),
              ].join('\n');
              const signature = (await tronWeb.trx.signMessageV2(message, ACCOUNT_KEY)).slice(2); // r‖s‖v, hex

              const response = await fetch(`${url}?${canonical}&signature=${signature}`, {
                method,
                headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
                body: body === undefined ? undefined : text, // the bytes that were hashed, unchanged
              });
              if (response.status === 204) return null; // a DELETE has no body
              const json = await response.json();
              if (!response.ok) throw new Error(`${response.status} ${json.code}: ${json.message}`);
              return json;
            }

            let cursor = null;
            do {
              const page = await call('GET', '/transactions', { query: cursor ? { limit: 25, cursor } : { limit: 25 } });
              for (const t of page.items) console.log(t.at, t.kind, t.amount, t.state);
              cursor = page.nextCursor; // null on the last page
            } while (cursor);
        - lang: python
          label: Python
          source: |
            # GET /{address}/transactions: everything that moved the credit
            # Python 3.9 or later:  pip install coincurve pycryptodome base58 requests
            import hashlib
            import json
            import os
            import time
            from urllib.parse import quote, urlsplit

            import base58
            import requests
            from Crypto.Hash import keccak
            from coincurve import PrivateKey

            # Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
            API = "https://api.crypto.dashing.ws/prepaid"
            NODE = "https://api.trongrid.io"
            ACCOUNT_KEY = os.environ["ACCOUNT_KEY"]  # hex; the account's key signs every request


            def keccak256(data: bytes) -> bytes:
                return keccak.new(digest_bits=256, data=data).digest()


            def address_of(private_key_hex: str) -> str:
                """0x41 and the last 20 bytes of keccak(public key), in base58check."""
                public = PrivateKey(bytes.fromhex(private_key_hex)).public_key.format(compressed=False)[1:]
                return base58.b58encode_check(b"\x41" + keccak256(public)[-20:]).decode()


            ACCOUNT = address_of(ACCOUNT_KEY)


            def call(method: str, path: str, query: dict = None, body: dict = None) -> dict:
                """One request, signed by the account's key.

                The signed message is five lines:
                  Dashing Crypto prepaid v1
                  METHOD
                  the path as sent, network prefix included     /prepaid/T.../transfers
                  the query without signature, encoded, sorted  issued=1790467200&limit=25
                  SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
                signed as a TRON message (TIP-191): keccak-256 over "\\x19TRON Signed Message:\\n",
                the message's length in bytes and the message; secp256k1; r || s || v, v 27 or 28.
                """
                full_path = f"{urlsplit(API).path}/{ACCOUNT}{path}"
                params = dict(query or {}, issued=int(time.time()))
                encode = lambda value: quote(str(value), safe="-._~")  # RFC 3986
                canonical = "&".join(f"{k}={v}" for k, v in sorted((encode(k), encode(v)) for k, v in params.items()))
                data = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
                message = "\n".join([
                    "Dashing Crypto prepaid v1",
                    method,
                    full_path,
                    canonical,
                    hashlib.sha256(data).hexdigest(),
                ]).encode()

                digest = keccak256(b"\x19TRON Signed Message:\n" + str(len(message)).encode() + message)
                raw = PrivateKey(bytes.fromhex(ACCOUNT_KEY)).sign_recoverable(digest, hasher=None)
                signature = (raw[:64] + bytes([raw[64] + 27])).hex()

                origin = "{0.scheme}://{0.netloc}".format(urlsplit(API))
                response = requests.request(
                    method,
                    f"{origin}{full_path}?{canonical}&signature={signature}",
                    data=data if body is not None else None,  # the bytes that were hashed, unchanged
                    headers={"Content-Type": "application/json"} if body is not None else {},
                    timeout=30,
                )
                if response.status_code == 204:  # a DELETE has no body
                    return None
                result = response.json()
                if not response.ok:
                    raise RuntimeError(f"{response.status_code} {result['code']}: {result['message']}")
                return result


            cursor = None
            while True:
                page = call("GET", "/transactions", query={"limit": 25, **({"cursor": cursor} if cursor else {})})
                for t in page["items"]:
                    print(t["at"], t["kind"], t["amount"], t["state"])
                cursor = page["nextCursor"]  # None on the last page
                if not cursor:
                    break
        - lang: java
          label: Java
          source: |
            // GET /{address}/transactions: everything that moved the credit
            // Java 17 or later, with org.web3j:crypto:5.0.0 and com.fasterxml.jackson.core:jackson-databind
            import com.fasterxml.jackson.databind.JsonNode;
            import com.fasterxml.jackson.databind.ObjectMapper;
            import java.io.ByteArrayOutputStream;
            import java.math.BigDecimal;
            import java.math.BigInteger;
            import java.net.URI;
            import java.net.http.HttpClient;
            import java.net.http.HttpRequest;
            import java.net.http.HttpResponse;
            import java.nio.charset.StandardCharsets;
            import java.util.Arrays;
            import java.util.Comparator;
            import java.util.Map;
            import java.util.stream.Collectors;
            import org.web3j.crypto.ECKeyPair;
            import org.web3j.crypto.Hash;
            import org.web3j.crypto.Keys;
            import org.web3j.crypto.Sign;
            import org.web3j.utils.Numeric;

            public class Prepaid {

                // Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
                static final String API = "https://api.crypto.dashing.ws/prepaid";
                static final String NODE = "https://api.trongrid.io";
                static final ECKeyPair ACCOUNT_KEY = key(System.getenv("ACCOUNT_KEY")); // signs every request
                static final String ACCOUNT = addressOf(ACCOUNT_KEY);
                static final ObjectMapper JSON = new ObjectMapper();
                static final HttpClient HTTP = HttpClient.newHttpClient();

                public static void main(String[] args) throws Exception {
            String cursor = null;
            do {
                JsonNode page = call("GET", "/transactions",
                        cursor == null ? Map.of("limit", "25") : Map.of("limit", "25", "cursor", cursor), null);
                for (JsonNode t : page.path("items")) {
                    System.out.println(t.path("at").asText() + " " + t.path("kind").asText() + " "
                            + t.path("amount").asText() + " " + t.path("state").asText());
                }
                cursor = page.path("nextCursor").isNull() ? null : page.path("nextCursor").asText(); // null on the last page
            } while (cursor != null);
                }

                /**
                 * One request, signed by the account's key. The signed message is five lines:
                 *
                 * <pre>
                 *   Dashing Crypto prepaid v1
                 *   METHOD
                 *   the path as sent, network prefix included     /prepaid/T…/transfers
                 *   the query without signature, encoded, sorted  issued=1790467200&amp;limit=25
                 *   SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
                 * </pre>
                 *
                 * signed as a TRON message (TIP-191): keccak-256 over "\x19TRON Signed Message:\n", the
                 * message's length in bytes and the message; secp256k1; r‖s‖v with v 27 or 28.
                 */
                static JsonNode call(String method, String path, Map<String, String> query, Object body) throws Exception {
                    URI api = URI.create(API);
                    String fullPath = api.getPath() + "/" + ACCOUNT + path;

                    Map<String, String> params = new java.util.HashMap<>(query);
                    params.put("issued", Long.toString(System.currentTimeMillis() / 1000));
                    String canonical = params.entrySet().stream()
                            .map(e -> new String[] {encode(e.getKey()), encode(e.getValue())})
                            .sorted(Comparator.<String[], String>comparing(p -> p[0]).thenComparing(p -> p[1]))
                            .map(p -> p[0] + "=" + p[1])
                            .collect(Collectors.joining("&"));

                    byte[] bytes = body == null ? new byte[0] : JSON.writeValueAsBytes(body);
                    byte[] message = String.join("\n",
                            "Dashing Crypto prepaid v1",
                            method,
                            fullPath,
                            canonical,
                            Numeric.toHexStringNoPrefix(Hash.sha256(bytes))).getBytes(StandardCharsets.UTF_8);
                    byte[] digest = Hash.sha3(concat( // keccak-256
                            ("\u0019TRON Signed Message:\n" + message.length).getBytes(StandardCharsets.UTF_8), message));
                    Sign.SignatureData sig = Sign.signMessage(digest, ACCOUNT_KEY, false); // v is 27 or 28
                    String signature = Numeric.toHexStringNoPrefix(concat(sig.getR(), sig.getS(), sig.getV()));

                    HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(
                            api.getScheme() + "://" + api.getAuthority() + fullPath + "?" + canonical + "&signature=" + signature));
                    if (body == null) {
                        request.method(method, HttpRequest.BodyPublishers.noBody());
                    } else { // the bytes that were hashed, unchanged
                        request.header("Content-Type", "application/json").method(method, HttpRequest.BodyPublishers.ofByteArray(bytes));
                    }
                    HttpResponse<String> response = HTTP.send(request.build(), HttpResponse.BodyHandlers.ofString());
                    if (response.statusCode() == 204) {
                        return null; // a DELETE has no body
                    }
                    JsonNode json = JSON.readTree(response.body());
                    if (response.statusCode() >= 400) {
                        throw new IllegalStateException(
                                response.statusCode() + " " + json.path("code").asText() + ": " + json.path("message").asText());
                    }
                    return json;
                }

                /** RFC 3986: A–Z a–z 0–9 - . _ ~ stay; every other byte is %XX in upper-case hex. */
                static String encode(String value) {
                    StringBuilder out = new StringBuilder();
                    for (byte b : value.getBytes(StandardCharsets.UTF_8)) {
                        int c = b & 0xFF;
                        boolean unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
                                || c == '-' || c == '.' || c == '_' || c == '~';
                        out.append(unreserved ? String.valueOf((char) c) : String.format("%%%02X", c));
                    }
                    return out.toString();
                }

                static final String BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";

                static ECKeyPair key(String hex) {
                    return ECKeyPair.create(new BigInteger(hex, 16));
                }

                /** 0x41 and the last 20 bytes of keccak(public key), in base58check. */
                static String addressOf(ECKeyPair key) {
                    byte[] body = concat(new byte[] {0x41}, Numeric.hexStringToByteArray(Keys.getAddress(key.getPublicKey())));
                    byte[] bytes = concat(body, Arrays.copyOf(Hash.sha256(Hash.sha256(body)), 4));
                    StringBuilder out = new StringBuilder();
                    for (BigInteger v = new BigInteger(1, bytes); v.signum() > 0; v = v.divide(BigInteger.valueOf(58))) {
                        out.append(BASE58.charAt(v.mod(BigInteger.valueOf(58)).intValue()));
                    }
                    return out.reverse().toString();
                }

                static byte[] concat(byte[]... parts) {
                    ByteArrayOutputStream out = new ByteArrayOutputStream();
                    for (byte[] part : parts) out.writeBytes(part);
                    return out.toByteArray();
                }
            }
      # x-codeSamples: end
      description: |
        Deposits, fees, sends and returns, newest first, a page at a time.
        `limit` is 1 to 100, 25 by default. Signed by the account's key, or read
        with a read token from
        `POST /{address}/tokens`.

        **Paging.** The first request has no `cursor`:

        ```text
        GET /prepaid/TXYZ…/transactions?limit=25&issued=…&signature=…
        ```

        While there is more, the response ends with `nextCursor`:

        ```json
        { "items": [ … ], "nextCursor": "eyJrIjoiMjAyNi0wOS0yNlQx…" }
        ```

        For the next page, send it back unchanged as the `cursor` query
        parameter, with the same `limit`, and sign the request as usual: the
        cursor is part of the signed query. With a read token there is nothing
        to sign: send the cursor and the limit.

        ```text
        GET /prepaid/TXYZ…/transactions?cursor=eyJrIjoiMjAyNi0wOS0yNlQx…&limit=25&issued=…&signature=…
        ```

        The last page has `nextCursor: null`. A cursor is opaque, so do not
        build or change one, and it stays good for a day. An account with no
        history gets `{ "items": [], "nextCursor": null }`.
      parameters:
        - $ref: '#/components/parameters/Address'
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - name: cursor
          in: query
          description: The previous page's `nextCursor`, unchanged; leave it out for the first page
          schema: { type: string, maxLength: 512, example: eyJrIjoiMjAyNi0wOS0yNlQx… }
        - $ref: '#/components/parameters/ReadIssued'
      security: [{ signature: [] }, { readToken: [] }]
      responses:
        '200':
          description: A page of transactions
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TransactionPage' }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '401': { $ref: '#/components/responses/UnauthorizedRead' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /{address}/transactions/{id}:
    get:
      tags: [History]
      summary: One transaction, for following a send or a deposit
      operationId: getTransaction
      # x-codeSamples: generated by scripts/prepaid-samples.mjs from scripts/prepaid-samples/; edit there
      x-codeSamples:
        - lang: shell
          label: cURL
          source: |
            # GET /{address}/transactions/{id}: follow a send or a deposit
            # Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid
            # ACCOUNT is the address of the account, READ_TOKEN a token from POST /{address}/tokens, and
            # TRANSACTION_ID the id a send or a deposit returned.
            curl -s "https://api.crypto.dashing.ws/prepaid/$ACCOUNT/transactions/$TRANSACTION_ID" -H "Authorization: Bearer $READ_TOKEN"
            # {"id":"…","kind":"SEND","state":"CONFIRMED","txId":"…",…}: SUBMITTED until it settles
        - lang: javascript
          label: Node.js
          source: |
            // GET /{address}/transactions/{id}: follow a send or a deposit
            // Node 18 or later:  npm install tronweb@6
            import { createHash } from 'node:crypto';
            import { TronWeb } from 'tronweb';

            // Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
            const API = 'https://api.crypto.dashing.ws/prepaid';
            const NODE = 'https://api.trongrid.io';
            const ACCOUNT_KEY = process.env.ACCOUNT_KEY; // hex; the account's key signs every request
            const ACCOUNT = TronWeb.address.fromPrivateKey(ACCOUNT_KEY);
            const tronWeb = new TronWeb({ fullHost: NODE });

            // Every request is signed by the account's key. The signed message is five lines:
            //   Dashing Crypto prepaid v1
            //   METHOD
            //   the path as sent, network prefix included     /prepaid/T…/transfers
            //   the query without signature, encoded, sorted  issued=1790467200&limit=25
            //   SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
            // signed as a TRON message (TIP-191), which is what signMessageV2 does.
            async function call(method, path, { query = {}, body } = {}) {
              const url = new URL(`${API}/${ACCOUNT}${path}`);
              const encode = (s) =>
                encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
              const canonical = Object.entries({ ...query, issued: Math.floor(Date.now() / 1000) })
                .map(([name, value]) => [encode(name), encode(String(value))])
                .sort(([a, x], [b, y]) => (a === b ? (x < y ? -1 : 1) : a < b ? -1 : 1))
                .map(([name, value]) => `${name}=${value}`)
                .join('&');
              const text = body === undefined ? '' : JSON.stringify(body);
              const message = [
                'Dashing Crypto prepaid v1',
                method,
                url.pathname,
                canonical,
                createHash('sha256').update(text, 'utf8').digest('hex'),
              ].join('\n');
              const signature = (await tronWeb.trx.signMessageV2(message, ACCOUNT_KEY)).slice(2); // r‖s‖v, hex

              const response = await fetch(`${url}?${canonical}&signature=${signature}`, {
                method,
                headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
                body: body === undefined ? undefined : text, // the bytes that were hashed, unchanged
              });
              if (response.status === 204) return null; // a DELETE has no body
              const json = await response.json();
              if (!response.ok) throw new Error(`${response.status} ${json.code}: ${json.message}`);
              return json;
            }

            // Follow a send or a deposit until it settles.
            const id = process.env.TRANSACTION_ID; // the id a send or a deposit returned
            for (;;) {
              const t = await call('GET', `/transactions/${id}`);
              console.log(t.state, t.reason ?? '');
              if (['CONFIRMED', 'CREDITED', 'FAILED', 'REJECTED'].includes(t.state)) break;
              await new Promise((resolve) => setTimeout(resolve, 3000));
            }
        - lang: python
          label: Python
          source: |
            # GET /{address}/transactions/{id}: follow a send or a deposit
            # Python 3.9 or later:  pip install coincurve pycryptodome base58 requests
            import hashlib
            import json
            import os
            import time
            from urllib.parse import quote, urlsplit

            import base58
            import requests
            from Crypto.Hash import keccak
            from coincurve import PrivateKey

            # Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
            API = "https://api.crypto.dashing.ws/prepaid"
            NODE = "https://api.trongrid.io"
            ACCOUNT_KEY = os.environ["ACCOUNT_KEY"]  # hex; the account's key signs every request


            def keccak256(data: bytes) -> bytes:
                return keccak.new(digest_bits=256, data=data).digest()


            def address_of(private_key_hex: str) -> str:
                """0x41 and the last 20 bytes of keccak(public key), in base58check."""
                public = PrivateKey(bytes.fromhex(private_key_hex)).public_key.format(compressed=False)[1:]
                return base58.b58encode_check(b"\x41" + keccak256(public)[-20:]).decode()


            ACCOUNT = address_of(ACCOUNT_KEY)


            def call(method: str, path: str, query: dict = None, body: dict = None) -> dict:
                """One request, signed by the account's key.

                The signed message is five lines:
                  Dashing Crypto prepaid v1
                  METHOD
                  the path as sent, network prefix included     /prepaid/T.../transfers
                  the query without signature, encoded, sorted  issued=1790467200&limit=25
                  SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
                signed as a TRON message (TIP-191): keccak-256 over "\\x19TRON Signed Message:\\n",
                the message's length in bytes and the message; secp256k1; r || s || v, v 27 or 28.
                """
                full_path = f"{urlsplit(API).path}/{ACCOUNT}{path}"
                params = dict(query or {}, issued=int(time.time()))
                encode = lambda value: quote(str(value), safe="-._~")  # RFC 3986
                canonical = "&".join(f"{k}={v}" for k, v in sorted((encode(k), encode(v)) for k, v in params.items()))
                data = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
                message = "\n".join([
                    "Dashing Crypto prepaid v1",
                    method,
                    full_path,
                    canonical,
                    hashlib.sha256(data).hexdigest(),
                ]).encode()

                digest = keccak256(b"\x19TRON Signed Message:\n" + str(len(message)).encode() + message)
                raw = PrivateKey(bytes.fromhex(ACCOUNT_KEY)).sign_recoverable(digest, hasher=None)
                signature = (raw[:64] + bytes([raw[64] + 27])).hex()

                origin = "{0.scheme}://{0.netloc}".format(urlsplit(API))
                response = requests.request(
                    method,
                    f"{origin}{full_path}?{canonical}&signature={signature}",
                    data=data if body is not None else None,  # the bytes that were hashed, unchanged
                    headers={"Content-Type": "application/json"} if body is not None else {},
                    timeout=30,
                )
                if response.status_code == 204:  # a DELETE has no body
                    return None
                result = response.json()
                if not response.ok:
                    raise RuntimeError(f"{response.status_code} {result['code']}: {result['message']}")
                return result


            # Follow a send or a deposit until it settles.
            transaction_id = os.environ["TRANSACTION_ID"]  # the id a send or a deposit returned
            while True:
                t = call("GET", f"/transactions/{transaction_id}")
                print(t["state"], t["reason"] or "")
                if t["state"] in ("CONFIRMED", "CREDITED", "FAILED", "REJECTED"):
                    break
                time.sleep(3)
        - lang: java
          label: Java
          source: |
            // GET /{address}/transactions/{id}: follow a send or a deposit
            // Java 17 or later, with org.web3j:crypto:5.0.0 and com.fasterxml.jackson.core:jackson-databind
            import com.fasterxml.jackson.databind.JsonNode;
            import com.fasterxml.jackson.databind.ObjectMapper;
            import java.io.ByteArrayOutputStream;
            import java.math.BigDecimal;
            import java.math.BigInteger;
            import java.net.URI;
            import java.net.http.HttpClient;
            import java.net.http.HttpRequest;
            import java.net.http.HttpResponse;
            import java.nio.charset.StandardCharsets;
            import java.util.Arrays;
            import java.util.Comparator;
            import java.util.Map;
            import java.util.stream.Collectors;
            import org.web3j.crypto.ECKeyPair;
            import org.web3j.crypto.Hash;
            import org.web3j.crypto.Keys;
            import org.web3j.crypto.Sign;
            import org.web3j.utils.Numeric;

            public class Prepaid {

                // Mainnet. On Nile: https://api.crypto.dashing.ws/nile/prepaid and https://nile.trongrid.io
                static final String API = "https://api.crypto.dashing.ws/prepaid";
                static final String NODE = "https://api.trongrid.io";
                static final ECKeyPair ACCOUNT_KEY = key(System.getenv("ACCOUNT_KEY")); // signs every request
                static final String ACCOUNT = addressOf(ACCOUNT_KEY);
                static final ObjectMapper JSON = new ObjectMapper();
                static final HttpClient HTTP = HttpClient.newHttpClient();

                public static void main(String[] args) throws Exception {
            // Follow a send or a deposit until it settles.
            String id = System.getenv("TRANSACTION_ID"); // the id a send or a deposit returned
            for (;;) {
                JsonNode t = call("GET", "/transactions/" + id, Map.of(), null);
                System.out.println(t.path("state").asText() + " " + t.path("reason").asText(""));
                if (t.path("state").asText().matches("CONFIRMED|CREDITED|FAILED|REJECTED")) break;
                Thread.sleep(3000);
            }
                }

                /**
                 * One request, signed by the account's key. The signed message is five lines:
                 *
                 * <pre>
                 *   Dashing Crypto prepaid v1
                 *   METHOD
                 *   the path as sent, network prefix included     /prepaid/T…/transfers
                 *   the query without signature, encoded, sorted  issued=1790467200&amp;limit=25
                 *   SHA-256 of the body as sent, lower-case hex   (of "" when there is no body)
                 * </pre>
                 *
                 * signed as a TRON message (TIP-191): keccak-256 over "\x19TRON Signed Message:\n", the
                 * message's length in bytes and the message; secp256k1; r‖s‖v with v 27 or 28.
                 */
                static JsonNode call(String method, String path, Map<String, String> query, Object body) throws Exception {
                    URI api = URI.create(API);
                    String fullPath = api.getPath() + "/" + ACCOUNT + path;

                    Map<String, String> params = new java.util.HashMap<>(query);
                    params.put("issued", Long.toString(System.currentTimeMillis() / 1000));
                    String canonical = params.entrySet().stream()
                            .map(e -> new String[] {encode(e.getKey()), encode(e.getValue())})
                            .sorted(Comparator.<String[], String>comparing(p -> p[0]).thenComparing(p -> p[1]))
                            .map(p -> p[0] + "=" + p[1])
                            .collect(Collectors.joining("&"));

                    byte[] bytes = body == null ? new byte[0] : JSON.writeValueAsBytes(body);
                    byte[] message = String.join("\n",
                            "Dashing Crypto prepaid v1",
                            method,
                            fullPath,
                            canonical,
                            Numeric.toHexStringNoPrefix(Hash.sha256(bytes))).getBytes(StandardCharsets.UTF_8);
                    byte[] digest = Hash.sha3(concat( // keccak-256
                            ("\u0019TRON Signed Message:\n" + message.length).getBytes(StandardCharsets.UTF_8), message));
                    Sign.SignatureData sig = Sign.signMessage(digest, ACCOUNT_KEY, false); // v is 27 or 28
                    String signature = Numeric.toHexStringNoPrefix(concat(sig.getR(), sig.getS(), sig.getV()));

                    HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(
                            api.getScheme() + "://" + api.getAuthority() + fullPath + "?" + canonical + "&signature=" + signature));
                    if (body == null) {
                        request.method(method, HttpRequest.BodyPublishers.noBody());
                    } else { // the bytes that were hashed, unchanged
                        request.header("Content-Type", "application/json").method(method, HttpRequest.BodyPublishers.ofByteArray(bytes));
                    }
                    HttpResponse<String> response = HTTP.send(request.build(), HttpResponse.BodyHandlers.ofString());
                    if (response.statusCode() == 204) {
                        return null; // a DELETE has no body
                    }
                    JsonNode json = JSON.readTree(response.body());
                    if (response.statusCode() >= 400) {
                        throw new IllegalStateException(
                                response.statusCode() + " " + json.path("code").asText() + ": " + json.path("message").asText());
                    }
                    return json;
                }

                /** RFC 3986: A–Z a–z 0–9 - . _ ~ stay; every other byte is %XX in upper-case hex. */
                static String encode(String value) {
                    StringBuilder out = new StringBuilder();
                    for (byte b : value.getBytes(StandardCharsets.UTF_8)) {
                        int c = b & 0xFF;
                        boolean unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
                                || c == '-' || c == '.' || c == '_' || c == '~';
                        out.append(unreserved ? String.valueOf((char) c) : String.format("%%%02X", c));
                    }
                    return out.toString();
                }

                static final String BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";

                static ECKeyPair key(String hex) {
                    return ECKeyPair.create(new BigInteger(hex, 16));
                }

                /** 0x41 and the last 20 bytes of keccak(public key), in base58check. */
                static String addressOf(ECKeyPair key) {
                    byte[] body = concat(new byte[] {0x41}, Numeric.hexStringToByteArray(Keys.getAddress(key.getPublicKey())));
                    byte[] bytes = concat(body, Arrays.copyOf(Hash.sha256(Hash.sha256(body)), 4));
                    StringBuilder out = new StringBuilder();
                    for (BigInteger v = new BigInteger(1, bytes); v.signum() > 0; v = v.divide(BigInteger.valueOf(58))) {
                        out.append(BASE58.charAt(v.mod(BigInteger.valueOf(58)).intValue()));
                    }
                    return out.reverse().toString();
                }

                static byte[] concat(byte[]... parts) {
                    ByteArrayOutputStream out = new ByteArrayOutputStream();
                    for (byte[] part : parts) out.writeBytes(part);
                    return out.toByteArray();
                }
            }
      # x-codeSamples: end
      description: |
        One line of the history, by the `id` a send or a deposit returned.
        Signed by the account's key, or read with a read token from
        `POST /{address}/tokens`.
      security: [{ signature: [] }, { readToken: [] }]
      parameters:
        - $ref: '#/components/parameters/Address'
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/ReadIssued'
      responses:
        '200':
          description: The transaction
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Transaction' }
        '401': { $ref: '#/components/responses/UnauthorizedRead' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

components:
  parameters:
    Address:
      name: address
      in: path
      required: true
      description: The account, a TRON address whose key signs its requests
      schema: { $ref: '#/components/schemas/Address' }
    Issued:
      name: issued
      in: query
      required: true
      description: Epoch seconds when the request was signed; within five minutes of our clock either way
      schema: { type: integer, format: int64, example: 1790467200 }
    Signature:
      name: signature
      in: query
      required: true
      description: Over the five-line message in Signing
      schema: { $ref: '#/components/schemas/Signature' }
    ReadIssued:
      name: issued
      in: query
      required: false
      description: As `issued` on a signed request; leave it out when the request carries a read token
      schema: { type: integer, format: int64, example: 1790467200 }

  securitySchemes:
    signature:
      type: apiKey
      in: query
      name: signature
      description: |
        The request signed by the account's key, with `issued` beside it: the
        five-line message in Signing.
    readToken:
      type: http
      scheme: bearer
      bearerFormat: prt_…
      description: |
        A read token from `POST /{address}/tokens`, in place of `issued` and
        `signature` on a read. Good until it goes 180 days unused.

  responses:
    ValidationFailed:
      description: A malformed body, address or transaction
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: VALIDATION_FAILED, message: "to is not a TRON address" }
    Unauthorized:
      description: The signature does not recover to the account address, or is stale
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: UNAUTHORIZED, message: "The request is not signed by the account address" }
    UnauthorizedRead:
      description: Neither a good signature nor a read token that still reads this account
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            unsigned:
              value: { code: UNAUTHORIZED, message: "Every request carries issued and signature in its query; a read may carry a read token instead" }
            expired:
              value: { code: TOKEN_EXPIRED, message: "This read token has lapsed or was revoked; sign POST /{address}/tokens for a new one" }
    Forbidden:
      description: The account or the sending address is refused after a reverted transaction
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: FORBIDDEN, message: "This sending address is no longer served" }
    NotFound:
      description: No such transaction on this account
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: NOT_FOUND, message: "No such transaction" }
    RateLimited:
      description: |
        Too many requests for this account or from this IP. Wait `retryAfter`
        seconds before the next request; the standard `Retry-After` header
        carries the same number for clients that read it there.
      headers:
        Retry-After:
          description: Seconds to wait, the same as `retryAfter` in the body
          schema: { type: integer, minimum: 1 }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/RateLimitError' }
          example: { code: RATE_LIMITED, message: "Too many requests; try again in 12 seconds", retryAfter: 12 }
    Unavailable:
      description: A fault on our side; nothing was charged
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: SPONSORSHIP_UNAVAILABLE, message: "Sending is paused right now; nothing was charged" }

  schemas:
    Address:
      type: string
      description: A TRON address, base58
      pattern: '^T[1-9A-HJ-NP-Za-km-z]{33}$'
      example: TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf
    Amount:
      type: string
      description: A USDT amount as a decimal string with up to six places
      pattern: '^-?[0-9]+(\.[0-9]{1,6})?$'
      example: "20.00"
    TxId:
      type: string
      description: A TRON transaction id, 32 bytes as hex
      pattern: '^[0-9a-f]{64}$'
      example: 7c2d3e8f9a1b0c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5
    Signature:
      type: string
      description: 65 bytes r‖s‖v as hex, over the signed message
      pattern: '^[0-9a-f]{130}$'
      example: 1b2c3d…
    Error:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          enum:
            - VALIDATION_FAILED
            - UNAUTHORIZED
            - TOKEN_EXPIRED
            - FORBIDDEN
            - NOT_FOUND
            - CONFLICT
            - INSUFFICIENT_BALANCE
            - INSUFFICIENT_CREDIT
            - DEPOSIT_BELOW_MINIMUM
            - DEPOSIT_UNCLAIMABLE
            - SPONSORED_FEE_CHANGED
            - PRICE_CHANGED
            - TRANSACTION_REJECTED
            - TRANSACTION_EXPIRED
            - RATE_LIMITED
            - SPONSORSHIP_UNAVAILABLE
        message:
          type: string
          description: |
            Plain English words, safe to show to a person. They are the same in
            every language: to show an error in your reader's language, word it
            yourself from `code`, `reason` and the figure fields below, which are
            stable.
        reason:
          type: string
          description: |
            Which of the cases `code` covers, where it covers several. Lower
            snake case. Left out when the code has only one case. New values may
            appear: treat one you do not know as the code alone.

            - `SPONSORSHIP_UNAVAILABLE`: `disabled`, `paused`, `no_supplier`,
              `network_unreachable`, `energy_price_high`, `not_measurable`,
              `not_priced`, `spend_capped`, `busy`, `busy_refund_pending`,
              `internal`
            - `DEPOSIT_UNCLAIMABLE`: `not_seen` (not on chain yet; claim it
              again later), `failed` (failed on chain), `not_a_deposit`,
              `more_than_exists`
            - `DEPOSIT_BELOW_MINIMUM`: `below_fee` (with `fee`), `below_minimum`
              (with `minimum`)
            - `INSUFFICIENT_BALANCE`: `deposit`, `send`
            - `CONFLICT`: `in_flight`, `used_elsewhere`
            - `UNAUTHORIZED`: `unsigned`, `clock_skew`, `wrong_signer`,
              `token_read_only`, `malformed_token`
            - `FORBIDDEN`: `sponsored_deposits_revoked`, `address_refused`
            - `TRANSACTION_REJECTED`: `not_usdt_transfer`, `mismatch`,
              `wrong_signer`, `fee_limit_low` (with `limit`)
            - `VALIDATION_FAILED`: `invalid_address`, `amount_invalid`,
              `amount_not_positive`, `sending_to_self`, `to_treasury`,
              `memo_too_long` (with `limit`)
          example: below_minimum
        price:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: What one send costs now, on `PRICE_CHANGED` and `INSUFFICIENT_CREDIT`
        fee:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: The fee now, on `SPONSORED_FEE_CHANGED` and `DEPOSIT_BELOW_MINIMUM` `below_fee`
        minimum:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: The smallest deposit, on `DEPOSIT_BELOW_MINIMUM` `below_minimum`
        limit:
          type: integer
          description: |
            The limit the request went past: the longest memo in characters on
            `memo_too_long`, or the fee limit to sign with, in sun, on
            `fee_limit_low`

    RateLimitError:
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          required: [retryAfter]
          properties:
            retryAfter:
              type: integer
              minimum: 1
              description: Seconds to wait before the next request

    Account:
      type: object
      required: [address, currency, credit, deposit]
      properties:
        address: { $ref: '#/components/schemas/Address' }
        currency: { type: string, enum: [USDT] }
        credit:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: What sends can take from; `0.00` for an address that has never deposited
        deposit: { $ref: '#/components/schemas/DepositTerms' }
      example:
        address: TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf
        currency: USDT
        credit: "10.00"
        deposit:
          treasury: TNk6vBq3wG8sR2yH5mJ9cX4pL7eD1aF6zU
          minimum: "10.00"
          sponsored: { available: true, fee: "0.00" }
    Terms:
      type: object
      required: [currency, deposit]
      properties:
        currency: { type: string, enum: [USDT] }
        deposit: { $ref: '#/components/schemas/DepositTerms' }
      example:
        currency: USDT
        deposit:
          treasury: TNk6vBq3wG8sR2yH5mJ9cX4pL7eD1aF6zU
          minimum: "20.00"
          sponsored: { available: true, fee: "0.00" }
    ReadToken:
      type: object
      required: [token]
      properties:
        token:
          type: string
          description: "Shown once. Send as `Authorization: Bearer prt_…` on a read"
          pattern: '^prt_[A-Za-z0-9_-]{43}$'
      example:
        token: prt_Jx3rV0c9Qm2WbT8yLk5ZpN1sHd7eGf4uAo6iRq2tCvE
    DepositTerms:
      type: object
      description: How this address can top up its credit now. Read it before each deposit; it can change.
      required: [treasury, minimum, sponsored]
      properties:
        treasury:
          allOf: [{ $ref: '#/components/schemas/Address' }]
          description: Where to send the USDT
        minimum:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: The least this address may send in one deposit now
        sponsored:
          type: object
          description: Whether we will broadcast the deposit and pay the network for it, and what we take for that
          required: [available, fee]
          properties:
            available:
              type: boolean
              description: False while we cannot sponsor a deposit, or no longer do for this account; send it yourself instead
            fee:
              allOf: [{ $ref: '#/components/schemas/Amount' }]
              description: |
                Taken from a sponsored deposit before it is credited; `0.00`
                when we pay the network for you. This is the fee as of this
                response. It rarely changes, but it can change before your
                deposit arrives; the deposit is charged the fee at that moment.
                To refuse a higher one, send `maxSponsoredFee` with it.

    DepositRequest:
      description: One of the two ways in, told apart by which fields are present
      oneOf:
        - $ref: '#/components/schemas/BroadcastDeposit'
        - $ref: '#/components/schemas/SponsoredDeposit'
    BroadcastDeposit:
      title: A · You broadcast it
      type: object
      description: You sent the USDT to the treasury and paid the network yourself; this claims it
      required: [txId]
      additionalProperties: false
      properties:
        txId:
          allOf: [{ $ref: '#/components/schemas/TxId' }]
          description: The id of your transfer to the treasury
      example:
        txId: 7c2d3e8f9a1b0c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5
    SponsoredDeposit:
      title: B · We broadcast it (sponsored)
      type: object
      description: |
        The transfer to the treasury, signed by `{address}` and not yet
        broadcast. We pay the network for it, broadcast it, and credit
        `amount − sponsoredFee`. Build it as a TRC20 `transfer` on the USDT
        contract to `deposit.treasury`, `fee_limit` 20 TRX, with an expiration
        two to ten minutes away when it arrives; five is right.
      required: [signedTransaction]
      additionalProperties: false
      properties:
        signedTransaction:
          type: string
          description: The whole signed transfer as hex
        maxSponsoredFee:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: |
            Optional. The most you accept us taking for sponsoring it, usually
            the `deposit.sponsored.fee` you read. If the fee is now higher, the
            request is refused with `409` and nothing is spent. Without it, the
            fee at the moment the request arrives is taken.
      example:
        signedTransaction: 0a02c4e12208…
    Deposit:
      type: object
      required: [id, state, amount]
      properties:
        id: { type: string, format: uuid }
        state:
          type: string
          enum: [SPONSORING, PENDING, CREDITED, FAILED, REJECTED]
          description: SPONSORING while we pay the network for it and broadcast it; PENDING until the block is solid
        txId: { $ref: '#/components/schemas/TxId' }
        amount: { $ref: '#/components/schemas/Amount' }
        sponsoredFee:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: What sponsoring it took, zero or more; absent for a deposit you sent yourself
        credited:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: amount − sponsoredFee, present once CREDITED
        credit:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: The credit after this deposit, present once CREDITED
        reason: { type: string, nullable: true, description: Plain English words when FAILED or REJECTED }
        reasonCode:
          type: string
          description: |
            The cause behind `reason`, stable and in lower snake case, so it can
            be worded in any language. Left out when there is no reason, and on
            rows written before API 1.9.0. Values: `not_started`, `not_carried`,
            `failed_on_chain`, `reverted`, `duplicate`, `stale_block`, `expired`,
            `signature`, `too_big`, `refused`, `too_near_expiry`,
            `activation_failed`, `rental_failed`, `resources_not_seen`,
            `not_taken`, `never_broadcast`, `internal`. New values may appear.
      example: { id: 2f7b6d1a-0f7e-4d6e-8c3b-9a1d2e3f4a55, state: CREDITED, txId: 7c2d3e8f9a1b0c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5, amount: "20.00", sponsoredFee: "0.00", credited: "20.00", credit: "30.00", reason: null }

    Transaction:
      type: object
      required: [id, kind, amount, state, at]
      properties:
        id: { type: string, format: uuid }
        kind:
          type: string
          enum: [DEPOSIT, SPONSORED_FEE, SEND, RETURN]
          description: DEPOSIT credits; SPONSORED_FEE is what sponsoring a deposit took; SEND is a price debited; RETURN is a price given back after a failure on our side before any energy reached the sending address
        amount:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: Signed; what it did to the credit
        state:
          type: string
          enum: [SPONSORING, PENDING, CREDITED, SUBMITTED, RENTING, DELEGATED, BROADCAST, CONFIRMED, FAILED, REJECTED]
          description: |
            By kind. `SEND`: `SUBMITTED → RENTING → DELEGATED → BROADCAST →
            CONFIRMED | FAILED`. `DEPOSIT`: `SPONSORING` (we broadcast it) or
            `PENDING` (you did, not yet final), then `CREDITED` or `FAILED`.
            `SPONSORED_FEE` is `CONFIRMED`; `RETURN` is `CREDITED`.
        txId: { $ref: '#/components/schemas/TxId' }
        from: { $ref: '#/components/schemas/Address' }
        to: { $ref: '#/components/schemas/Address' }
        sent:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: For a SEND, the USDT that moved from `from` to `to`
        memo: { type: string, nullable: true, description: Your own note from the send, kept here and never written on-chain }
        reason:
          type: string
          nullable: true
          description: Plain English words when FAILED or REJECTED, and whether the charge stood
        reasonCode:
          type: string
          description: |
            The cause behind `reason`, stable and in lower snake case, with the
            same values as on a deposit. It names the cause only: whether a
            send's price came back is the RETURN line with the same `txId`.
            Left out when there is no reason, and on rows written before API
            1.9.0.
        at:
          type: string
          format: date-time
          description: When it happened, by our clock
      example: { id: 4e1a0c2e-7b1e-4a7e-9a2d-2b3a3f1e5c11, kind: SEND, amount: "-1.50", state: CONFIRMED, txId: 9d1e2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6, from: TS3y1nKd6vQ2X9mJ4hR8cW5eL7pF2aB9zN, to: TBqv4mX2eK9pL7cR5dW8nH3jF6aZ1yG4tQ, sent: "100.00", memo: null, reason: null, at: "2026-09-27T11:40:09Z" }
    TransactionPage:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/Transaction' }
        nextCursor:
          type: string
          nullable: true
          description: The next page's `cursor` query parameter; null on the last page
      example:
        items:
          - { id: 4e1a0c2e-7b1e-4a7e-9a2d-2b3a3f1e5c11, kind: SEND, amount: "-1.50", state: CONFIRMED, txId: 9d1e2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6, from: TS3y1nKd6vQ2X9mJ4hR8cW5eL7pF2aB9zN, to: TBqv4mX2eK9pL7cR5dW8nH3jF6aZ1yG4tQ, sent: "100.00", memo: null, reason: null, at: "2026-09-27T11:40:09Z" }
          - { id: 2f7b6d1a-0f7e-4d6e-8c3b-9a1d2e3f4a55, kind: DEPOSIT, amount: "20.00", state: CREDITED, txId: 7c2d3e8f9a1b0c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5, memo: null, reason: null, at: "2026-09-27T10:03:41Z" }
        nextCursor: eyJrIjoiMjAyNi0wOS0yNlQx…

    QuoteRequest:
      type: object
      required: [from, to, amount]
      properties:
        from: { $ref: '#/components/schemas/Address' }
        to: { $ref: '#/components/schemas/Address' }
        amount: { $ref: '#/components/schemas/Amount' }
      example:
        from: TS3y1nKd6vQ2X9mJ4hR8cW5eL7pF2aB9zN
        to: TBqv4mX2eK9pL7cR5dW8nH3jF6aZ1yG4tQ
        amount: "100.00"
    Quote:
      type: object
      required: [price, currency, recipientHoldsToken, credit]
      properties:
        price:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: What the send would take from the credit, as of this response
        currency: { type: string, enum: [USDT] }
        recipientHoldsToken:
          type: boolean
          description: True when the recipient already holds USDT, which is the cheaper case
        credit:
          type: object
          required: [before, after]
          properties:
            before: { $ref: '#/components/schemas/Amount' }
            after: { $ref: '#/components/schemas/Amount' }
      example: { price: "1.50", currency: USDT, recipientHoldsToken: true, credit: { before: "19.00", after: "17.50" } }

    TransferRequest:
      type: object
      required: [from, to, amount, signedTransaction]
      properties:
        from: { $ref: '#/components/schemas/Address' }
        to: { $ref: '#/components/schemas/Address' }
        amount: { $ref: '#/components/schemas/Amount' }
        memo: { type: string, nullable: true, maxLength: 200, description: Your own note, kept with the send in the history; never written on-chain }
        maxPrice:
          allOf: [{ $ref: '#/components/schemas/Amount' }]
          description: |
            Optional. The most you accept the send taking from the credit,
            usually the quote's `price`. If the price is now higher, the send
            is refused with `409` and nothing is charged. Without it, the price
            when the send arrives is taken.
        signedTransaction:
          type: string
          description: The whole signed USDT transfer as hex, signed by `from`
      example:
        from: TS3y1nKd6vQ2X9mJ4hR8cW5eL7pF2aB9zN
        to: TBqv4mX2eK9pL7cR5dW8nH3jF6aZ1yG4tQ
        amount: "100.00"
        memo: null
        signedTransaction: 0a02c4e12208…
