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
| Parameter | Value |
|---|---|
| Namespace | minter |
| Mainnet | minter:1 |
| Testnet | minter: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
namespacesafter approval: there is aminternamespace, the needed network, theminter_sendTransactionmethod and exactly one account. Otherwise the app disconnects.The address in the account is lowercase. The app compares addresses only in lowercase.
sessionProperties.minter_txTypesis an optional hint: a JSON array of the transaction type numbers the wallet is ready to sign (sessionPropertiesvalues in WalletConnect are strings). The final answer is theunsupported_tx_typeerror 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_requestAccountstakes noparams(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:paramsexactly 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 field | Type | Required | Meaning |
|---|---|---|---|
from | string | yes | The address of the session account. If it does not match it, the error wrong_wallet. |
type | number | yes | The Minter transaction type number, decimal (table §4). Not a hex string. |
data | object | yes | The type's fields (§4). |
payload | string | no | UTF-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. |
gasCoin | number | no | The 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] |
description | string | no | The 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 errorinvalid_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 (
0is 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,coinsSymbolsand the like): they are unknown fields, and a request carrying them getsinvalid_params. The wallet shows the symbol itself, by id from its own node.Addresses are
Mx+ 40 hex; validator public keys areMp+ 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
paramsanddataare the errorinvalid_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.
type | hex | Name | data fields |
|---|---|---|---|
| 1 | 0x01 | Send | to, coin, value |
| 2 | 0x02 | SellCoin | coinToSell, valueToSell, coinToBuy, minimumValueToBuy |
| 3 | 0x03 | SellAllCoin | coinToSell, coinToBuy, minimumValueToBuy |
| 4 | 0x04 | BuyCoin | coinToBuy, valueToBuy, coinToSell, maximumValueToSell |
| 7 | 0x07 | Delegate | publicKey, coin, value |
| 8 | 0x08 | Unbond | publicKey, coin, value |
| 13 | 0x0D | Multisend | list: an array of {coin, to, value}, from 1 to 100 elements |
| 21 | 0x15 | AddLiquidity | coin0, coin1, volume0, maximumVolume1 |
| 22 | 0x16 | RemoveLiquidity | coin0, coin1, liquidity, minimumVolume0, minimumVolume1 |
| 23 | 0x17 | SellSwapPool | coins, valueToSell, minimumValueToBuy |
| 24 | 0x18 | BuySwapPool | coins, valueToBuy, maximumValueToSell |
| 25 | 0x19 | SellAllSwapPool | coins, minimumValueToBuy |
| 27 | 0x1B | MoveStake | from, to (Mp… keys), coin, value |
| 28 | 0x1C | MintToken | coin, value |
| 29 | 0x1D | BurnToken | coin, value |
| 30 | 0x1E | CreateToken | name, symbol, initialAmount, maxSupply, mintable, burnable |
| 34 | 0x22 | CreateSwapPool | coin0, coin1, volume0, volume1 |
| 35 | 0x23 | AddLimitOrder | coinToSell, valueToSell, coinToBuy, valueToBuy |
| 36 | 0x24 | RemoveLimitOrder | id |
Notes:
The
coin*,coins[]fields are coin ids (a number from 0 to 2^32 - 1). Thevalue*,volume*,liquidity,initialAmount,maxSupplyfields are strings in pip. A limit orderidis a number from 1 to 2^32 - 1 (uint32, as in the node).mintable,burnablearetrue/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].minimumValueToBuyandmaximumValueToSellare 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) andsymbol(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
listelements 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
| Event | data | When |
|---|---|---|
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:
resultis aMt+ 64 hex string;a transaction with this hash is found in a block through a node the app trusts;
the sender is the session account, and
typeand every field ofdataandpayloadmatch 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;nameandsymbolup 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 ororderIdgreater than 2^32 - 1, a coin symbol that is not a string or is longer than 1024, a missing,nullor 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;nonceis not less, and the block height is greater, than before the request (protection against an old hash);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" } } }code | data.reason | Meaning |
|---|---|---|
| 5000 | user_rejected | The user rejected |
| 5100 | unsupported_chain | The network is not in the session or in the wallet |
| 5101 | unsupported_method | The method is not supported |
| 6000 | disconnected | The session is closed |
| -32602 | invalid_params | params did not parse: an unknown field, a wrong format, a sent nonce/chainId/gasPrice that differs |
| -32000 | wrong_wallet | from is not the session account |
| -32000 | unsupported_tx_type | The wallet does not sign this type |
| -32000 | busy | There is already a pending request |
| -32000 | payload_too_long | payload is longer than 1024 bytes |
| -32000 | limit_exceeded | A wallet ceiling is exceeded |
| -32000 | price_deviation | The swap, pool or order price is too far from the market |
| -32000 | route_unavailable | There 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 |
| -32000 | forbidden_recipient | The 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) |
| -32000 | insufficient_funds | Not enough funds for the amount and the fee |
| -32000 | node_rejected | The node rejected the transaction before the block; data.log is the node's text |
| -32000 | broadcast_unknown | The 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.
9. Link signing (wallets without WalletConnect)
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/v1The 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).
9.3. Ids and links
An id is a kind prefix and 22 base64url characters (128 random bits):
| Prefix | Kind | Lifetime |
|---|---|---|
tx_ | sign and send one transaction | 5 min by default, at most 15 min; 2 min inside a session |
cn_ | tell the DEX your address | 10 min |
ss_ | grant a session permission | 10 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
| Mode | Allowed type |
|---|---|
| Per transaction | 2 SellCoin, 3 SellAllCoin, 4 BuyCoin, 23 SellSwapPool, 24 BuySwapPool, 25 SellAllSwapPool |
| Session | 23, 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 reporthashandaddressinresult. 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 be0(BIP) or a coin from the session list.Amounts — pip strings
^[1-9][0-9]{0,77}$. Coin ids — integers0…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"
}| Field | Type | Kinds | Meaning |
|---|---|---|---|
v | integer | all | Format version, 1. Unknown version — refuse. |
kind | string | all | "tx", "connect" or "session". MUST match the id prefix (tx_, cn_, ss_). |
id | string | all | MUST equal the id from the link. |
chainId | string | all | "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). |
source | string | all | Host 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, expiresAt | integer | all | Unix seconds. |
params | object | tx | §3.1 with §9.4. |
session, seq | string, integer | tx in a session | Session id and strictly increasing sequence number (§9.7). |
limits | object | session | Requested limits (§9.7). |
confirmCode | string | session | 6 digits, shown also on the DEX. |
claimToken | string | all, except requests delivered by GET /sessions/{sid}/next | Opaque, 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 path | Auth | Result |
|---|---|---|
POST /requests/{id}/claim | — | 200 envelope. Works once: second call — 409 already_claimed. |
POST /requests/{id}/result | Authorization: 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=25 | Bearer <walletToken> | Long poll. 200 envelope of the next request (it is claimed by this call) or 204 after wait seconds. |
POST /sessions/{sid}/revoke | Bearer <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
}| Field | Rule |
|---|---|
types | Non-empty subset of [23, 24, 25]. |
coins | 2–10 distinct ids. Every coin of a route, including intermediate ones, MUST be in the list. gasCoin MUST be 0 or in the list. |
sellCaps | Per 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. |
maxSlippageBps | 1…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. |
maxTxCount | 1…200, default 20. |
durationSec | 1…86400 (24 h), counted by the wallet's clock from approval, regardless of the server's expiresAt. |
Also:
seqMUST 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
revokecall 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}/nextwith thewalletTokenmay 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 answersresult. 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" } }| HTTP | code | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed body or id |
| 401 | unauthorized | Missing or wrong token |
| 404 | not_found | No such id (also returned for ids that never existed) |
| 409 | already_claimed | Request already claimed |
| 409 | already_answered | result already received |
| 410 | expired | Request expired or cancelled by the DEX |
| 410 | session_ended | Session expired, ended or revoked |
| 413 | too_large | Body over 4 KB |
| 429 | rate_limited | Too many requests; see Retry-After |
| 501 | not_enabled | Sessions are not enabled on the DEX yet |
| 503 | capacity | Server 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):
reason | Meaning |
|---|---|
expired | expiresAt passed by the wallet's clock, or lies more than 15 min ahead |
replayed | Id already used, or seq not increasing |
wrong_source | source, chainId, kind or id do not match |
type_not_allowed | Type not allowed by this channel or session |
coin_not_allowed | Coin of the route or gasCoin not in the session list |
limit_exceeded | Per-trade or total cap, or maxTxCount, exceeded |
slippage_exceeded | Limit price worse than allowed by maxSlippageBps |
price_deviation | Per transaction: limit price too far from the wallet's quote |
quote_unavailable | The wallet could not get its own quote |
session_expired | Session duration passed by the wallet's clock |
session_revoked | User revoked the session |
9.10. Wallet checklist
The wallet MUST:
Hard-code the base URL; take only the id from a link, matching the regex of §9.3.
Show the service name from its own configuration; refuse if
sourceis not the host of the base URL.sourceis 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.Check that
chainIdequals the wallet's network (production:"minter:1"only); on mismatch refuse withwrong_source. Never sign a request whosechainIddiffers from the chain id of the signature.Check
expiresAtby its own clock: refuse if expired or more than 15 minutes ahead.Keep used ids for their lifetime and never sign the same id twice.
Parse
paramsstrictly (§3, §4, §9.4): only allowed types, nopayload, nodescription, 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.Set nonce, gas price and chain id itself; broadcast itself.
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 refuseminimumValueToBuy = 0(price_deviation).Per transaction: ask the user every time. Never sign a request that came via a link without a prompt.
Sessions (if supported): store the limits locally, enforce all of §9.7 by its own copy, never let the server widen them, refuse
quote_unavailablewhen no quote, let the user see the remaining caps and revoke at any time.Keep
claimTokenandwalletTokenin 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
claimedbefore 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; atxrequest 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
resultas 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 exacttypeanddata). No hash and no address — status "unknown, check your wallet".The address from
connectis "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.
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.
Create a wallet: name, icon, website, emergency contact (an email address, confirmed by a link).
Add one or more connect methods (below).
Prove that you control every bot and domain you use (below).
Optionally press Send test request and answer it from your wallet (sandbox).
Send the wallet to review. A Deneb moderator checks it and publishes it once.
Fields.
| Field | Rules |
|---|---|
| Name | 2–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. |
| Icon | PNG 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. |
| Website | https://. Seen by moderators; not shown on the DEX in this version. |
| Emergency contact | Email for incidents and automatic disabling. |
Connect methods.
| Method | Template |
|---|---|
| Telegram Mini App | https://t.me/<bot>/<app>?startapp=deneb_{id} |
| Telegram bot | https://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:1 | No template: a marker that your wallet supports §1–8. The DEX uses the WalletConnect flow for it. |
Templates are
https://only, host exactlyt.meor exactly your verified host, port 443, no user info, no#. Exactly one{id}placeholder, only inside the value ofstartapp,startor 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.jsonwith{"tokens":["<token>"]}, then press Check. The exact host is verified, over HTTPS on port 443, without following redirects. Hosts under public suffixes (such asgithub.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":
/setabouttextin @BotFather orsetMyShortDescriptionin the Bot API), then press Check. Deneb reads the bot's public profile athttps://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; oryour 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
| Version | Date | What changed |
|---|---|---|
| 0.1 | 2026-10-16 | Draft for agreement |
| 0.2 | 2026-10-20 | §2.1: the Deneb wallet window, the minter_requestAccounts answer, the remembered-connection screen; §7: route_unavailable |
| 0.3 | 2026-10-21 | §9: link signing for wallets without WalletConnect; §9.12: registering your wallet in the developer cabinet |
| 0.4 | 2026-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 |