Developers

WalletConnect for Minter

Draft: the format is being agreed with the first partners; after freeze fields are only added, never change meaning. Places that may change before the freeze are marked [draft].

This document describes how an app (dApp) asks a Minter wallet to sign and send a transaction over WalletConnect v2 (Sign API). It is the same for both sides:

  • wallet (Deneb and any other Minter wallet): accepts the connection and the requests;

  • app (the Deneb DEX and any other dApp): connects the wallet and sends requests.

The words "must", "must not", "may" are used in the sense of RFC 2119.

Wallets without WalletConnect (Telegram bots, Mini Apps) connect by a link with a single id: §9.

1. Namespace and networks

ParameterValue
Namespaceminter
Mainnetminter:1
Testnetminter:2
Account (CAIP-10)minter:1:Mx + 40 lowercase hex characters

The network reference (1, 2) is the chainId that goes into the signed Minter transaction. The network launch name (minter-mainnet-5) is not part of the identifier: it changes when the network is updated, chainId does not.

The wallet must check that the chainId in the transaction it signs equals the network reference of the request. The wallet must not sign a request for a network that is not in the session.

2. Connecting

The app proposes a session with optionalNamespaces (the requiredNamespaces field in WalletConnect is deprecated):

{
  "optionalNamespaces": {
    "minter": {
      "chains": ["minter:1"],
      "methods": ["minter_sendTransaction"],
      "events": ["accountsChanged", "chainChanged"]
    }
  }
}

The wallet approves the session with one account:

{
  "namespaces": {
    "minter": {
      "chains": ["minter:1"],
      "accounts": ["minter:1:Mx1111111111111111111111111111111111111111"],
      "methods": ["minter_sendTransaction"],
      "events": ["accountsChanged", "chainChanged"]
    }
  },
  "sessionProperties": {
    "minter_txTypes": "[1,2,3,4,23,24,25]"
  }
}
  • The app must check namespaces after approval: there is a minter namespace, the needed network, the minter_sendTransaction method and exactly one account. Otherwise the app disconnects.

  • The address in the account is lowercase. The app compares addresses only in lowercase.

  • sessionProperties.minter_txTypes is an optional hint: a JSON array of the transaction type numbers the wallet is ready to sign (sessionProperties values in WalletConnect are strings). The final answer is the unsupported_tx_type error on the request. [draft]

  • Both sides show the other side's metadata (name, description, url, icons) as untrusted text: no markup, no link navigation, length trimmed. The wallet is recommended to show the WalletConnect Verify result (verified domain or not).

How to pass the wc: URI to the wallet: a QR code, copying the string, or the wallet's link (for Deneb: deneb://wc?uri=<URI in percent-encoding>). [draft] the links of other wallets, including a Telegram mini-app.

2.1. The Deneb wallet window (web)

On the web, the Deneb DEX connects the Deneb wallet not over WalletConnect but with a popup window on the wallet's origin (https://wallet.denebnav.app/minter/wallet/connect). The transport, the origin checks and the source and rid of both sides are in ADR 0032 §2.1. Only sites from the list baked into the wallet build use the window (today only the Deneb DEX); other apps connect over WalletConnect, as described above.

The window has two methods. Errors use the same code and data.reason as in §7.

  • minter_requestAccounts takes no params (the wallet does not read them). Success is a JSON array with exactly one address of the open wallet, lowercase (like the account in §2):

{ "v": 1, "kind": "response", "rid": "…", "id": 1, "result": ["Mx1111111111111111111111111111111111111111"] }

The app accepts only an array of length 1 with a Mx + 40 hex string and compares addresses in lowercase. A user rejection is user_rejected.

  • minter_sendTransaction: params exactly as in §3.1, the answer as in §3.2: the hash string.

Remembered connection. The "Remember for 30 days" checkbox in the window saves the permission in the wallet's storage on the wallet's origin, bound to the site's origin (site origin, wallet, address, expiry). This is only knowledge of the address; it does not grant the right to sign. With a live permission the window still shows the "This site is connected until …" screen with the address and three buttons: "Continue" (answer: the array with the address), "Forget site" (the permission is erased, then the normal connection screen) and "Cancel" (user_rejected). One user click is required; there is no auto-reply: without a click the window answers nothing and does not close itself. The app must not expect an instant answer and must not open the window without a user action.

3. The minter_sendTransaction method

The wallet builds the transaction from params, shows it to the user, signs it, sends it to the network itself and returns the hash.

3.1. Request

{
  "topic": "…",
  "chainId": "minter:1",
  "request": {
    "method": "minter_sendTransaction",
    "params": {
      "from": "Mx1111111111111111111111111111111111111111",
      "type": 23,
      "data": {
        "coins": [0, 1993],
        "valueToSell": "10000000000000000000",
        "minimumValueToBuy": "4950000000000000000"
      },
      "payload": "",
      "description": "Swap on DEX"
    },
    "expiry": 1792150000
  }
}
params fieldTypeRequiredMeaning
fromstringyesThe address of the session account. If it does not match it, the error wrong_wallet.
typenumberyesThe Minter transaction type number, decimal (table §4). Not a hex string.
dataobjectyesThe type's fields (§4).
payloadstringnoUTF-8 text, at most 1024 bytes (the node allows up to 10 000; the protocol is stricter). Empty by default. The wallet shows it in full. TAB, LF, CR, U+2028 and U+2029 are allowed; other control characters, bidi and formatting characters (Cf), including ZWJ (U+200D), are forbidden. A binary payload (not UTF-8) is not supported yet: we will return to it after the partner's answer.
gasCoinnumbernoThe coin id for the fee. The wallet may pick another one if the user does not hold this coin, and must show the choice. [draft]
descriptionstringnoThe app's explanation, up to 140 characters, one line. Control characters, bidi and formatting characters (Cf) are forbidden except ZWJ (U+200D) inside emoji. The wallet shows it with a "from the app" note and never instead of the parsed fields.

General rules:

  • Amounts are decimal strings in pip (1 coin = 10^18 pip), digits only, no sign, dot or exponent: "1000000000000000000". JSON numbers and fractional values are not allowed for amounts. [draft] (in minter-js-sdk amounts are in coin units; here they are in pip, as in the transaction itself).

  • Zero amounts. The main amounts and maxima (value, valueToSell, valueToBuy, maximumValueToSell, volume0, volume1, maximumVolume1, liquidity, initialAmount, maxSupply) must be greater than zero; "0" is the error invalid_params. The minima (minimumValueToBuy, minimumVolume0, minimumVolume1) may be "0". minimumValueToBuy: "0" is a swap without slippage protection: the wallet shows a warning about it and does not sign silently.

  • Coins are only by id, a JSON number (0 is BIP). A coin's symbol does not identify it: it is text from the network that can have look-alike doubles. The protocol has no symbol hints (coinSymbol, coinsSymbols and the like): they are unknown fields, and a request carrying them gets invalid_params. The wallet shows the symbol itself, by id from its own node.

  • Addresses are Mx + 40 hex; validator public keys are Mp + 64 hex.

  • The wallet sets nonce, chainId, gasPrice, signatureType. The app must not send them. If it does and the values differ from the wallet's, the wallet rejects (invalid_params) rather than silently replacing them.

  • Multisig is not supported.

  • One pending request per session. A second one is the error busy.

  • Unknown fields in params and data are the error invalid_params. The wallet signs only what it understood completely.

3.2. Response

Success is the hash of the sent transaction, a Mt + 64 hex string:

{ "id": 1792149700123, "jsonrpc": "2.0", "result": "Mt5a1c…e0b9" }

Only the string. An object { "hash": "Mt…" } and any other shapes are a wrong answer.

The hash is not proof of execution. The app must find the transaction in a block through its own node and check it against the request (§6).

4. Transaction types and data fields

The protocol defines the format for every type below. Which of them a particular wallet signs is the wallet's decision; for the rest it answers unsupported_tx_type. The "hex" column is the same number as minter-js-sdk writes it.

typehexNamedata fields
10x01Sendto, coin, value
20x02SellCoincoinToSell, valueToSell, coinToBuy, minimumValueToBuy
30x03SellAllCoincoinToSell, coinToBuy, minimumValueToBuy
40x04BuyCoincoinToBuy, valueToBuy, coinToSell, maximumValueToSell
70x07DelegatepublicKey, coin, value
80x08UnbondpublicKey, coin, value
130x0DMultisendlist: an array of {coin, to, value}, from 1 to 100 elements
210x15AddLiquiditycoin0, coin1, volume0, maximumVolume1
220x16RemoveLiquiditycoin0, coin1, liquidity, minimumVolume0, minimumVolume1
230x17SellSwapPoolcoins, valueToSell, minimumValueToBuy
240x18BuySwapPoolcoins, valueToBuy, maximumValueToSell
250x19SellAllSwapPoolcoins, minimumValueToBuy
270x1BMoveStakefrom, to (Mp… keys), coin, value
280x1CMintTokencoin, value
290x1DBurnTokencoin, value
300x1ECreateTokenname, symbol, initialAmount, maxSupply, mintable, burnable
340x22CreateSwapPoolcoin0, coin1, volume0, volume1
350x23AddLimitOrdercoinToSell, valueToSell, coinToBuy, valueToBuy
360x24RemoveLimitOrderid

Notes:

  • The coin*, coins[] fields are coin ids (a number from 0 to 2^32 - 1). The value*, volume*, liquidity, initialAmount, maxSupply fields are strings in pip. A limit order id is a number from 1 to 2^32 - 1 (uint32, as in the node). mintable, burnable are true / false.

  • For MoveStake (27) the amount is called value, as for Delegate and Unbond (7, 8) and in the node's proto.

  • coins (23-25) is the whole route from the coin being sold to the coin being bought, from 2 to 5 ids: [sell, …, buy].

  • minimumValueToBuy and maximumValueToSell are the limit the node will execute. The app sets it taking slippage into account. The wallet must not loosen it.

  • name (up to 64 characters) and symbol (3-10 characters, A-Z, 0-9) for CreateToken follow the node's rules. The wallet checks them for similarity to existing coins.

  • The order of the list elements for Multisend is part of the signature. The wallet shows every position.

  • Types not in the table (including CreateCoin with a reserve, validator creation and editing, multisig, checks, NFT) are not described by the protocol. The wallet answers unsupported_tx_type.

  • The wallet may impose its own limits (amount ceilings, price deviation from the market, recipient bans). A refusal over them is limit_exceeded, price_deviation, forbidden_recipient.

Field names are camelCase, as in minter-js-sdk (not snake_case, as in the node API).

Examples of data:

{ "type": 1,  "data": { "to": "Mx2222222222222222222222222222222222222222", "coin": 0, "value": "1500000000000000000" } }
{ "type": 24, "data": { "coins": [0, 1993], "valueToBuy": "5000000000000000000", "maximumValueToSell": "10100000000000000000" } }
{ "type": 21, "data": { "coin0": 0, "coin1": 1993, "volume0": "100000000000000000000", "maximumVolume1": "50500000000000000000" } }
{ "type": 35, "data": { "coinToSell": 0, "valueToSell": "100000000000000000000", "coinToBuy": 1993, "valueToBuy": "60000000000000000000" } }
{ "type": 36, "data": { "id": 4821 } }
{ "type": 30, "data": { "name": "Example Token", "symbol": "EXMPL", "initialAmount": "1000000000000000000000",
                        "maxSupply": "1000000000000000000000000", "mintable": true, "burnable": true } }
{ "type": 13, "data": { "list": [ { "coin": 0, "to": "Mx2222222222222222222222222222222222222222", "value": "1000000000000000000" } ] } }

The addresses and ids in the examples are illustrative, not real recipients.

5. Events

EventdataWhen
accountsChanged["minter:1:Mx…"]the user picked another account in the session
chainChanged"minter:1"the session network changed

On any event the app cancels its pending request and re-reads the session. A network the app does not support is a reason to disconnect. [draft] whether the wallet sends events and in what form.

6. What the app checks after the answer

For the app the wallet is an untrusted party. The app treats the operation as successful only when:

  1. result is a Mt + 64 hex string;

  2. a transaction with this hash is found in a block through a node the app trusts;

  3. the sender is the session account, and type and every field of data and payload match the request. The app reads the node's answer with the node's limits (payload up to 10 000 bytes), not with the request's limits. A value of the right shape but out of bounds is a mismatch: a signed transaction can carry such a value (RLP does not limit big numbers, strings and lists). Bounds: an amount no greater than 2^256 - 1; a route from 1 to 16 coins; name and symbol up to 1024 characters; payload base64 up to 10 000 bytes. An answer of a different shape (wrong keys, wrong @type, wrong value type, a negative or fractional number, a leading zero, a coin id or orderId greater than 2^32 - 1, a coin symbol that is not a string or is longer than 1024, a missing, null or non-base64 payload) is a node schema failure: neither "success" nor "the wallet sent the wrong thing". The app records the incident and shows "could not verify" together with the execution code from the block; the wallet is not flagged. A schema failure is final: the transaction is in the block, polling stops. A mismatch in the read fields matters more than a schema failure, and a schema failure matters more than the execution code;

  4. nonce is not less, and the block height is greater, than before the request (protection against an old hash);

  5. the execution code is 0.

If the transaction is absent for a long time, the status is "unknown", neither success nor error: the app keeps watching the hash and the account nonce. Cancelling the request on the app's side does not cancel the signature if the user already confirmed it in the wallet.

7. Errors

The error answer is standard JSON-RPC. code is from the table; data.reason is a stable string the app uses to pick the text for the user. message is for the developer log and is not shown in the UI.

{ "id": 1792149700123, "jsonrpc": "2.0",
  "error": { "code": -32000, "message": "Transaction type is not supported", "data": { "reason": "unsupported_tx_type" } } }
codedata.reasonMeaning
5000user_rejectedThe user rejected
5100unsupported_chainThe network is not in the session or in the wallet
5101unsupported_methodThe method is not supported
6000disconnectedThe session is closed
-32602invalid_paramsparams did not parse: an unknown field, a wrong format, a sent nonce/chainId/gasPrice that differs
-32000wrong_walletfrom is not the session account
-32000unsupported_tx_typeThe wallet does not sign this type
-32000busyThere is already a pending request
-32000payload_too_longpayload is longer than 1024 bytes
-32000limit_exceededA wallet ceiling is exceeded
-32000price_deviationThe swap, pool or order price is too far from the market
-32000route_unavailableThere is no quote for the request's route right now: a pool is missing or liquidity is short on one of the steps. Nothing is signed; the app offers another route or amount
-32000forbidden_recipientThe recipient or payload is forbidden by the wallet's rules (for example, the bridge address and the bridge instruction: a bridge withdrawal is made only through its own flow)
-32000insufficient_fundsNot enough funds for the amount and the fee
-32000node_rejectedThe node rejected the transaction before the block; data.log is the node's text
-32000broadcast_unknownThe transaction is signed but it is unknown whether it arrived; data.hash is its hash, the app watches it

Codes 5000, 5100, 5101 and 6000 are standard WalletConnect codes. The app must also understand the EIP-1193 codes (4001 for a user rejection, 4200 for a method that is not supported) if the wallet sends them. [draft] the list of data.reason.

8. What the protocol does not have

  • minter_signTransaction (sign without sending). The app could send a signed-but-not-sent transaction at any time while the nonce has not moved, including after disconnection. We will add it only if a partner really needs it, with a separate warning on the wallet screen.

  • minter_signMessage (signing arbitrary data). Minter has no message-signing standard, and a signature of arbitrary bytes with the same key could be passed off as a transaction signature. Sign-in by signature is a separate standard later.

  • Multisig, checks, validator creation and editing, NFT.

Status: draft, not frozen (2026-10-19). Rationale and threat model: ADR 0032, addendum of 19.10.2026 (in Russian). The service itself, its deployment and alerts: docs/infra/sign-api.md. This section is in English for wallet developers. Keywords MUST, MUST NOT, SHOULD, MAY — as in RFC 2119.

This channel lets a Minter wallet that has no WalletConnect (a Telegram bot, a Telegram Mini App, a mobile app with deep links) sign swaps for Deneb DEX. The transaction is not put into the link. The DEX stores a short-lived request on its server and passes only an opaque id to the wallet; the wallet fetches the request, shows it, signs it with its own key and nonce, broadcasts it and reports the hash back.

The transaction body is exactly params of minter_sendTransaction (§3.1, §4), with the narrowing of §9.4.

9.1. Flow

DEX (browser)                Deneb sign API                       Wallet
1. quote via own node
2. create request ─────────▶ validates, stores, returns id
3. opens link from the wallet registry:  https://t.me/<bot>?startapp=deneb_<id>  ─────────────▶
                                                                  4. POST /requests/{id}/claim  (once)
                             ──── envelope + claimToken ────────▶
                                                                  5. checks (§9.10), screen, user confirms,
                                                                     sign (own nonce), broadcast
                             ◀── POST /requests/{id}/result  {"status":"sent","hash":"Mt…","address":"Mx…"}
6. waits for the status
7. finds the tx in a block via its own node and matches it to the request (§6). Only this counts as success.

Two modes:

  • Per transaction (default). Every request is confirmed by the user in the wallet.

  • Session permission (optional, enabled on the DEX later; the format is final now). The user grants once a set of limits stored and enforced by the wallet. Within them the wallet MAY sign swap requests without asking again. Nothing on the DEX side can widen the limits.

9.2. Base URL and transport

https://dex.denebnav.app/api/sign/v1
  • The wallet MUST hard-code this base URL (per environment) and MUST NOT take an API address, callback URL or redirect from a link, a request or any other input.

  • HTTPS only. JSON bodies, Content-Type: application/json, UTF-8. Request bodies ≤ 4 KB.

  • Wallet endpoints send Access-Control-Allow-Origin: * and do not use cookies or credentials, so a Mini App can call them from its own origin and a bot can call them from its server.

  • Times are Unix seconds (integers).

An id is a kind prefix and 22 base64url characters (128 random bits):

PrefixKindLifetime
tx_sign and send one transaction5 min by default, at most 15 min; 2 min inside a session
cn_tell the DEX your address10 min
ss_grant a session permission10 min
sn_session (not a request: used only to wake a Mini App, §9.8)up to 24 h

The link parameter is deneb_<id>, 31 characters, safe for Telegram startapp (≤ 512) and bot start (≤ 64). The wallet MUST accept only

^deneb_(tx|cn|ss|sn)_[A-Za-z0-9_-]{22}$

and ignore anything else. The id is the only thing a wallet takes from a link.

Link templates. The DEX builds links only from its wallet registry. Each entry has a template with a single {id} placeholder, for example:

https://t.me/Crossover_crossover_bot?startapp=deneb_{id}
https://t.me/Crossover_crossover_bot?start=deneb_{id}
https://t.me/<bot>/<app>?startapp=deneb_{id}
https://wallet.example/sign?r=deneb_{id}

Only https:// templates on t.me or on the wallet's own verified domain (Universal Links / App Links). Custom URL schemes are not accepted. Wallet developers register their wallet and its templates in the Deneb developer cabinet (§9.12). The DEX labels every registered wallet as a third-party wallet, and the wallet can be disabled at any time without a release (GET /wallets/disabled).

Telegram passes start_param from any link anyone sends. Treat it as an untrusted string: it is only a handle to fetch a request from the hard-coded API.

9.4. What the channel carries

ModeAllowed type
Per transaction2 SellCoin, 3 SellAllCoin, 4 BuyCoin, 23 SellSwapPool, 24 BuySwapPool, 25 SellAllSwapPool
Session23, 24, 25 only

Further types (21, 22, 34, 35, 36) may be added later in a new version of this section. Send (1), Multisend (13), checks and every other type are never carried by this channel. The wallet MUST refuse them even if the server returns them.

Narrowing of params (§3.1) for this channel:

  • from — optional in per-transaction mode. If absent, the wallet signs with the active account and MUST report hash and address in result. If present, it MUST equal the signing account (wrong_wallet). In a session it is required and MUST equal the session address.

  • payload — absent or "". Anything else is refused (invalid_params).

  • description — not allowed (invalid_params). The channel carries no app text: the wallet shows its own name for the service ("Deneb DEX") from its own configuration.

  • gasCoin — optional, as in §3.1. In a session it MUST be 0 (BIP) or a coin from the session list.

  • Amounts — pip strings ^[1-9][0-9]{0,77}$. Coin ids — integers 0…4294967295. coins — 2 to 5 ids.

  • Unknown keys and duplicate keys anywhere — refused. nonce, chainId, gasPrice, signatureType — set by the wallet; if present, refused.

9.5. Envelope (response of claim)

{
  "v": 1,
  "kind": "tx",
  "id": "tx_EXAMPLEexampleEXAMPLE0",
  "chainId": "minter:1",
  "source": "dex.denebnav.app",
  "createdAt": 1792150000,
  "expiresAt": 1792150300,
  "params": {
    "from": "Mx1111111111111111111111111111111111111111",
    "type": 23,
    "data": { "coins": [0, 1993], "valueToSell": "10000000000000000000", "minimumValueToBuy": "4950000000000000000" },
    "gasCoin": 0
  },
  "claimToken": "EXAMPLE-claim-token"
}
FieldTypeKindsMeaning
vintegerallFormat version, 1. Unknown version — refuse.
kindstringall"tx", "connect" or "session". MUST match the id prefix (tx_, cn_, ss_).
idstringallMUST equal the id from the link.
chainIdstringall"minter:1"; test requests from the developer cabinet carry "minter:2" (§9.12). MUST equal the wallet's network, otherwise refuse with wrong_source (§9.10).
sourcestringallHost of the service, "dex.denebnav.app". MUST equal the host of the base URL. For cross-check only, never displayed as a trusted name.
createdAt, expiresAtintegerallUnix seconds.
paramsobjecttx§3.1 with §9.4.
session, seqstring, integertx in a sessionSession id and strictly increasing sequence number (§9.7).
limitsobjectsessionRequested limits (§9.7).
confirmCodestringsession6 digits, shown also on the DEX.
claimTokenstringall, except requests delivered by GET /sessions/{sid}/nextOpaque, authorises one result. Keep it in memory only, do not log it. Session requests are answered with the walletToken.

"connect" carries no params: the DEX only asks for the address.

9.6. Endpoints for wallets

The service, its deployment and alerts are described in docs/infra/sign-api.md.

Method and pathAuthResult
POST /requests/{id}/claim—200 envelope. Works once: second call — 409 already_claimed.
POST /requests/{id}/resultAuthorization: Bearer <claimToken> (session requests: <walletToken>)200 {} or, for an approved session, 200 {"session","walletToken","expiresAt"}. Accepted once; the first answer is final (409 already_answered).
GET /sessions/{sid}/next?wait=25Bearer <walletToken>Long poll. 200 envelope of the next request (it is claimed by this call) or 204 after wait seconds.
POST /sessions/{sid}/revokeBearer <walletToken>200 {}. Courtesy notice; revocation is local (§9.7).
GET /wallets/disabled—200 {"wallets":["<registryId>", …]} — registered wallets (§9.12) that are switched off.

result bodies:

{ "status": "sent", "hash": "Mt5a1c…e0b9", "address": "Mx1111111111111111111111111111111111111111" }
{ "status": "rejected", "reason": "user_rejected" }
{ "status": "rejected", "reason": "broadcast_unknown", "hash": "Mt5a1c…e0b9" }
{ "status": "approved", "address": "Mx1111111111111111111111111111111111111111" }
{ "status": "approved", "address": "Mx1111111111111111111111111111111111111111", "limits": { … } }

The first two are for tx, the fourth for connect, the last for session (limits — the limits the wallet actually stored, equal to or narrower than requested). hash — ^Mt[0-9a-f]{64}$.

Sending result is optional for tx with from, and strongly recommended: without the hash the DEX has to search the address history. It is required for tx without from, for connect and for session.

Cancellation. A request the wallet has not claimed is cancelled at once and becomes cancelled. Cancellation does not end a request the wallet has already claimed: the status becomes cancel_requested, sign-api still accepts the wallet's result until expiresAt plus the result grace (the transaction may already be on its way to the network), and only then the request becomes cancelled. After cancelling a claimed request the DEX keeps polling GET /requests/{id}/status?after=cancel_requested; if sent arrives, the DEX shows its hash, and only the block check of §9.11 makes it a success.

The DEX-side endpoints (create, status, cancel, session requests, end) are same-origin only and not part of this contract.

9.7. Session permission

Why it is bounded. Pool swaps (23, 24, 25) have no recipient: the result always goes to the signer. A compromised DEX or sign API can at worst make the user swap at a bad price within the limits. Worst-case loss of a session is about total × (maxSlippage + pool fees) + maxTxCount × network fee. That is why sessions allow nothing but pool swaps.

Grant. The DEX creates an ss_ request. The wallet shows all limits and confirmCode (the user compares it with the code on the DEX), and asks for the user's PIN or biometrics. The wallet stores the limits itself and checks every session request against its own copy. The user MAY narrow them before approving, never widen. The wallet answers result {"status":"approved","address","limits"} and receives session, walletToken, expiresAt. The wallet MUST NOT re-read limits from the server.

limits:

{
  "types": [23, 24, 25],
  "coins": [0, 1993, 2065],
  "sellCaps": [
    { "coin": 0,    "perTrade": "100000000000000000000", "total": "500000000000000000000" },
    { "coin": 1993, "perTrade": "50000000000000000000",  "total": "200000000000000000000" }
  ],
  "maxSlippageBps": 100,
  "maxTxCount": 20,
  "durationSec": 86400
}
FieldRule
typesNon-empty subset of [23, 24, 25].
coins2–10 distinct ids. Every coin of a route, including intermediate ones, MUST be in the list. gasCoin MUST be 0 or in the list.
sellCapsPer coin that may be sold: perTrade and total in that coin's pip, perTrade ≤ total. A coin without a cap may only be bought. Sold amount: type 23 — valueToSell; 24 — maximumValueToSell (worst case); 25 — the coin's balance at signing time. Counted when signed, whatever the outcome.
maxSlippageBps1…300 (3%), default 100. With the wallet's own quote Q for the same route: minimumValueToBuy ≥ ⌊Q × (10000 − bps) / 10000⌋; for 24 maximumValueToSell ≤ ⌈Q × (10000 + bps) / 10000⌉. Integer (bigint) arithmetic only. minimumValueToBuy = 0 — always refused.
maxTxCount1…200, default 20.
durationSec1…86400 (24 h), counted by the wallet's clock from approval, regardless of the server's expiresAt.

Also:

  • seq MUST be strictly greater than the last signed one; equal or lower — refuse (replayed).

  • One request at a time: the wallet takes the next one after it has answered the previous.

  • Revocation is local: the wallet deletes the stored limits and refuses everything for this session at once. A revoke call is a courtesy.

  • Type 25 (sell all): the wallet signs it only if the current balance does not exceed the remaining caps. An incoming transfer between signing and inclusion can raise the sold amount; a wallet MAY refuse 25 in a session (type_not_allowed), the DEX then retries as type 23 with an exact amount.

  • A request that arrives via a link is never signed automatically, even when a session with the DEX exists. Only requests fetched through GET /sessions/{sid}/next with the walletToken may be signed without a prompt.

9.8. Delivery of session requests

  • Server-side keys (bot). The wallet's server keeps a long poll on GET /sessions/{sid}/next, checks the limits next to the key, signs, broadcasts and answers result. Limit checks MUST run where the key is, not in the UI.

  • On-device keys (Mini App). The app polls only while it is open. When it is closed, requests expire after 2 minutes. The DEX shows "Open <wallet>" with a link deneb_sn_<sid>; it only wakes the app and grants nothing. If the wallet has no such session, it ignores the link.

9.9. Errors

HTTP errors of the sign API:

{ "error": { "code": "already_claimed", "message": "Request was already claimed" } }
HTTPcodeMeaning
400invalid_requestMalformed body or id
401unauthorizedMissing or wrong token
404not_foundNo such id (also returned for ids that never existed)
409already_claimedRequest already claimed
409already_answeredresult already received
410expiredRequest expired or cancelled by the DEX
410session_endedSession expired, ended or revoked
413too_largeBody over 4 KB
429rate_limitedToo many requests; see Retry-After
501not_enabledSessions are not enabled on the DEX yet
503capacityServer is at its limit; retry later

message is for developer logs, not for the UI.

reason values in result with "status":"rejected": all data.reason values of §7, plus the following (two of them, limit_exceeded and price_deviation, reuse §7 names with the meaning given here):

reasonMeaning
expiredexpiresAt passed by the wallet's clock, or lies more than 15 min ahead
replayedId already used, or seq not increasing
wrong_sourcesource, chainId, kind or id do not match
type_not_allowedType not allowed by this channel or session
coin_not_allowedCoin of the route or gasCoin not in the session list
limit_exceededPer-trade or total cap, or maxTxCount, exceeded
slippage_exceededLimit price worse than allowed by maxSlippageBps
price_deviationPer transaction: limit price too far from the wallet's quote
quote_unavailableThe wallet could not get its own quote
session_expiredSession duration passed by the wallet's clock
session_revokedUser revoked the session

9.10. Wallet checklist

The wallet MUST:

  1. Hard-code the base URL; take only the id from a link, matching the regex of §9.3.

  2. Show the service name from its own configuration; refuse if source is not the host of the base URL. source is only a string in the envelope: it does not prove that the request was created on this DEX. The quote check of item 8 is the real protection.

  3. Check that chainId equals the wallet's network (production: "minter:1" only); on mismatch refuse with wrong_source. Never sign a request whose chainId differs from the chain id of the signature.

  4. Check expiresAt by its own clock: refuse if expired or more than 15 minutes ahead.

  5. Keep used ids for their lifetime and never sign the same id twice.

  6. Parse params strictly (§3, §4, §9.4): only allowed types, no payload, no description, no unknown or duplicate keys, amounts as pip strings, coins by id, coin symbols from its own node. Display no text from the request except numbers and addresses.

  7. Set nonce, gas price and chain id itself; broadcast itself.

  8. Get its own quote from its own node and MUST compare minimumValueToBuy (or, for buys, maximumValueToSell) with it before signing. Per transaction: show "you receive at least X"; SHOULD warn if the limit is more than 3% worse than its quote; SHOULD refuse at 15% or more, and MUST refuse minimumValueToBuy = 0 (price_deviation).

  9. Per transaction: ask the user every time. Never sign a request that came via a link without a prompt.

  10. Sessions (if supported): store the limits locally, enforce all of §9.7 by its own copy, never let the server widen them, refuse quote_unavailable when no quote, let the user see the remaining caps and revoke at any time.

  11. Keep claimToken and walletToken in memory or secure storage; never log ids, tokens or addresses with them.

9.11. What the DEX checks

  • Builds links only from the published registry (§9.12); one request in flight per user action.

  • If a request turns claimed before the user opened the wallet, the DEX shows "Request was opened elsewhere", cancels it and offers a new one.

  • Phone. The link is opened by a button on this page. If the wallet claims the request before that button was pressed, the link was opened somewhere else (a forwarded link): the DEX shows "Request was opened elsewhere", cancels the request and, for connect, offers a new one; a tx request is started again from the swap form.

  • Desktop. The link is shown as a QR code, so a claim is the expected path and is not "somewhere else". The DEX shows "The request is open in the wallet. If that was not you, cancel the request"; the cancel button stays available.

  • Treats result as a hint. Success is only the transaction found in a block by its own node, matching the request as in §6 (by hash, or, without a hash, by the address from the saved height and nonce with exact type and data). No hash and no address — status "unknown, check your wallet".

  • The address from connect is "claimed" until the first matching transaction in a block. It is used only to show balances and quotes. There is deliberately no signed address proof (see §8).

9.12. Registering your wallet

Wallets are listed on the DEX through the developer cabinet at https://developers.denebnav.app, Integrations → Add → Wallet. There is no list to send us and nothing to change in Deneb code. DEX users find a published wallet in the connect sheet under Other wallets, marked as a third-party wallet. The picker is switched on for users after the first wallet passes moderation.

Steps.

  1. Sign in to the cabinet and turn on a passkey for your account. It is required for any account that owns a wallet sent to review or published.

  2. Create a wallet: name, icon, website, emergency contact (an email address, confirmed by a link).

  3. Add one or more connect methods (below).

  4. Prove that you control every bot and domain you use (below).

  5. Optionally press Send test request and answer it from your wallet (sandbox).

  6. Send the wallet to review. A Deneb moderator checks it and publishes it once.

Fields.

FieldRules
Name2–32 characters: letters of one script, digits, space, -, .. Names that look like "Deneb", "Minter" or another listed wallet are refused; "official" and "support" are not allowed.
IconPNG or JPEG, up to 512 KB, from 64×64 to 1024×1024, about square. We re-encode it to a 256×256 PNG and serve it ourselves; it is never loaded from your site. Icons close to the Deneb mark or to another wallet's icon wait for a moderator.
Websitehttps://. Seen by moderators; not shown on the DEX in this version.
Emergency contactEmail for incidents and automatic disabling.

Connect methods.

MethodTemplate
Telegram Mini Apphttps://t.me/<bot>/<app>?startapp=deneb_{id}
Telegram bothttps://t.me/<bot>?startapp=deneb_{id} or https://t.me/<bot>?start=deneb_{id}
Link on your domain (Universal Link / App Link)https://<your verified host>/<path>?<param>=deneb_{id}
WalletConnect minter:1No template: a marker that your wallet supports §1–8. The DEX uses the WalletConnect flow for it.
  • Templates are https:// only, host exactly t.me or exactly your verified host, port 443, no user info, no #. Exactly one {id} placeholder, only inside the value of startapp, start or your own parameter; nothing else is substituted. Up to 256 characters.

  • Custom URL schemes (yourwallet://…) are not accepted: on Android another app can register the same scheme.

  • QR is not a separate method: on desktop the DEX shows the chosen link as a QR code.

  • For each method also set the modes you support (per transaction, session) and, for sessions, the delivery (§9.8).

Ownership proof. The cabinet gives a token (valid 24 hours) bound to your account, the wallet and the bot or host.

  • Domain: serve https://<host>/.well-known/deneb-wallet.json with {"tokens":["<token>"]}, then press Check. The exact host is verified, over HTTPS on port 443, without following redirects. Hosts under public suffixes (such as github.io) are not accepted as a parent domain. Keep the file while the wallet is published: it is checked again.

  • Telegram bot or Mini App: put the token into the bot's short description ("about": /setabouttext in @BotFather or setMyShortDescription in the Bot API), then press Check. Deneb reads the bot's public profile at https://t.me/<bot>. After a successful check you may remove the token. A Mini App is verified through its bot.

Deneb does not accept a claim of a request as ownership proof: anyone who sees the link can call claim.

Sandbox. Send test request creates a tx or cn request with chainId: "minter:2" (testnet) and shows a link and a QR code for each method of your draft. A test tx is always type 23 (SellSwapPool) with an exact valueToSell: the smallest amount whose testnet quote gives a non-zero minimumValueToBuy, at most 10^15 pip (0.001 of a coin), on testnet coins whose ids do not exist on mainnet. Test requests are never created on mainnet and never appear on the DEX. The cabinet shows Works when:

  • your wallet signed it on testnet, reported the hash in result, and the transaction is in a testnet block with code 0; or

  • your wallet supports mainnet only and correctly refused with {"status":"rejected","reason":"wrong_source"}.

Otherwise it shows what went wrong: not claimed, claimed without result, sent without a hash, transaction not found or failed in the block, refused with another reason (shown as is), expired. Test requests are limited to 5 per hour per wallet.

Review and statuses. Draft → In review → Published → Disabled. A wallet goes to review only when all automatic checks pass: ownership of every method, template rules, name and icon, confirmed contact, passkey.

  • After publication you can edit the name, icon and website at any time. The name and icon go live after the automatic checks; a moderator may roll them back.

  • Changes to connect methods (template, domain, bot, Mini App, modes, delivery) and to the emergency contact go back to review with a fresh ownership proof for the new target. A moderator may confirm the change with your current contact. Until it is approved, the published version keeps working. You can withdraw a change from review.

Disabling.

  • You can disable your wallet in the cabinet at any time; it disappears from the DEX within a minute, no release is needed. You can enable it again yourself, with your passkey: if the ownership check still passes, it is published again, otherwise it goes to review.

  • Deneb can disable a wallet at any time, also without a release.

  • Deneb re-checks ownership every 24 hours. If the file is gone, or the bot was renamed, deleted or now looks like a different bot (another name), the wallet is disabled automatically and the emergency contact gets an email. A temporary failure to read the page or the file does not disable it.

  • A wallet disabled by Deneb or by the automatic check is enabled again only after review.

9.13. The wallet registry on the DEX side

The DEX reads the same signed snapshot as sign-api (GET /api/sign/v1/wallets, ADR 0032 Д4.2) and checks every record (name, icon, template, modes) by the rules of §9.12. A snapshot is accepted only when its version is not lower than the last one accepted in this tab; at the same version the signed content must be the same and issuedAt must not go back. That lower bound lives only in the tab's memory and is lost on reload.

After a reload the protection is the signature by a pinned key (keyId) and the freshness of issuedAt: a snapshot older than 15 minutes is refused. So an old envelope cannot bring back a wallet that was disabled since. For the connect sheet the verified list is kept in the tab's memory for up to 5 minutes; a failed fetch shows "The list is unavailable right now" instead of the previous list. Before every transaction the DEX fetches the registry again, past that cache, and sends no request to a wallet that is no longer published.

10. Versions

VersionDateWhat changed
0.12026-10-16Draft for agreement
0.22026-10-20§2.1: the Deneb wallet window, the minter_requestAccounts answer, the remembered-connection screen; §7: route_unavailable
0.32026-10-21§9: link signing for wallets without WalletConnect; §9.12: registering your wallet in the developer cabinet
0.42026-10-22§9.6: cancellation of a claimed request (cancel_requested, the grace for result, the DEX's continued polling); §9.10: source is not proof and the wallet must compare the limit with its own quote; §9.11: a claim before the button press on a phone ("opened elsewhere") and a claim by QR on a desktop; §9.13: the wallet registry on the DEX side, its version floor and the per-transaction refresh; §9: link to the sign-api operations doc