A web page never holds a QNet key. It finds the user's QNet wallet through a provider the wallet injects into the page, asks it for the user's address, and asks it to sign or send; the wallet shows every request to the user in its own window or sheet and does the signing itself. This document is the page-facing contract of that provider, as the two wallets implement it:
- the browser extension (
applications/qnet-wallet, contract inapplications/qnet-wallet/CONTRACTS.mdsection 4, internals in Browser wallet); - the in-app browser of the QNet app (
applications/qnet-mobile/src/browser, internals in Mobile wallet).
Both announce the same provider with the same methods, parameters, results, errors and events. Where they differ, the difference is listed under Differences between the wallets.
| Wallet | Pages | Announced channel |
|---|---|---|
| Browser extension, store build | top frame of https://aiqnet.io/* and https://games.aiqnet.io/*, and no other site |
extension |
Browser extension, development build (npm run build:dev in applications/qnet-wallet) |
the same, plus plain-HTTP http://localhost/* and http://127.0.0.1/* on any port |
extension |
| QNet app, in-app browser | top frame of every HTTPS page it opens, except the pages it never loads: every page of link.aiqnet.io, the site's /node, /activate, /wallet and /l pages, and the pages the site keeps out of the app's view (its home page, /docs, /dao, /testnet and /qnet-wallet-extension); for those it opens https://aiqnet.io/explorer instead |
mobile |
A page on any other host, or in a subframe, gets no provider.
The wallet dispatches a qnet:announceProvider event on window when the page starts and again each
time the page dispatches qnet:requestProvider. The event's detail is frozen:
| Field | Value |
|---|---|
info.uuid |
a random id per page load |
info.name |
'QNet Wallet' |
info.icon |
a data: URI |
info.rdns |
'io.aiqnet.wallet' |
info.channel |
'extension' or 'mobile' |
provider |
{isQNet: true, request({method, params}), on(event, listener), removeListener(event, listener)} |
The same object is also set as window.qnet (not writable). The announcement is the one to rely on:
the SDK's findWallet listens for it and falls back to window.qnet only when no announcement came.
// What findWallet does (development/qnet-sdk/src/provider.ts), without the SDK:
window.addEventListener('qnet:announceProvider', (event) => {
const { info, provider } = event.detail;
if (info.rdns !== 'io.aiqnet.wallet' || typeof provider.request !== 'function') return;
// info.channel is 'extension' or 'mobile'
});
window.dispatchEvent(new Event('qnet:requestProvider'));The rdns and channel fields choose a wallet; they do not authenticate it. What protects the user
is the wallet's own approval, which shows the page's origin as the wallet reads it, never a name or
icon the page supplies.
request({method, params}) returns a promise. It rejects with an Error that carries a numeric
code (Errors). A request whose JSON exceeds 16,384 characters is refused. There is no
timeout: an approval waits for the user, up to the wallet's own 10-minute limit.
| Method | Params | Result | Opens an approval |
|---|---|---|---|
qnet_requestAccounts |
none | {qnet, solana} |
when the site is not connected yet (the extension also opens one to unlock a locked wallet) |
qnet_accounts |
none | {qnet, solana}, or {} when the site is not connected or the wallet is locked |
never |
qnet_chainId |
none | {chainId: 'q1337', network: 'testnet'} |
never |
qnet_disconnect |
none | true; the site loses access and receives accountsChanged {} and disconnect |
never |
qnet_signMessage |
{message} |
{signature, publicKey, address} (hex, hex, EON address) |
every time |
qnet_sendTransaction |
one of the forms under Sending transactions | see Results | every time |
qnet_getTransactionStatus |
{from, nonce} |
{status, blockHeight, txHash} |
never |
qnet_activateNode |
{nodeType: 'light' | 'super'} |
see QNet Link v1 section 10 | extension only, and only for https://aiqnet.io |
qnet_getActivation |
none | what the extension knows of the wallet's activation (its code, a burn on its way, or whether its search found none), see QNet Link v1 section 10 | never; extension only, and only for https://aiqnet.io (a connected site, at most 30 reads a minute) |
qnet_claimNodeBalance |
none | the claim answer of QNet Link v1 section 14.7, without v and intent |
extension only, and only for https://aiqnet.io; moves the node balance of the wallet's own light node |
qnet_unlinkNodeDevice |
none | the unlink answer of QNet Link v1 section 14.7, without v and intent |
extension only, and only for https://aiqnet.io; ends the wallet's own light node on the device that runs it |
"None" means the params are absent, null, [] or {}; anything else is -32602.
qnet_signMessage, qnet_sendTransaction and qnet_getTransactionStatus need the site to be
connected first (else 4100). Any other method name is 4200.
qnet_signMessage signs text for this page's origin only. The message is 1 to 4,096 UTF-8 bytes; the
wallet refuses, before showing anything, a message that starts like a protocol message (such as a
transaction's q1337| tag), holds hidden or control characters, a carriage return not followed by a
line feed, typographic spaces the window would draw as nothing, or a lone surrogate. The signed bytes
and a server-side check are in Sign-in.
qnet_sendTransaction takes exactly one of these forms. Unknown keys are refused (-32602).
| Form | Fields |
|---|---|
| QNC transfer | {to, amount} or {type: 'transfer', to, amount}: to an EON address, amount QNC as canonical decimal text ("1.5"; no sign, exponent or leading zeros; at most 9 fraction digits), above zero |
| Built-in token transfer | {type: 'tokenTransfer', token, to, amount}: token the token contract's EON address, amount in the token's own units as decimal text (at most as many fraction digits as the token has decimals) |
| Contract call | {type: 'contractCall', contract, method, args, gasLimit?}: contract a WebAssembly contract's EON address; method matches ^[A-Za-z_][A-Za-z0-9_]{0,63}$; args the call input as hex of whole bytes, 0 to 4,096 bytes ("" for none; either case, taken as lowercase); gasLimit an optional JSON integer |
A contract call that names value or accessList is refused with the error
{code: -32602, message: 'Unsupported parameter', data: {reason: 'UNSUPPORTED_PARAM'}}: the network
accepts neither on a call today. A page cannot deploy a contract through the wallet; deploys are done
with the CLI.
What the wallet does with a request:
- It sets the nonce, gas price and gas limit itself and builds every signed byte with the shared
builders (
applications/qnet-mobile/src/crypto/TxBuilders.js, described in Transactions). - Token transfer. The wallet reads the token from the chain (
GET /api/v1/token/{contract}, the same answer from two nodes): it must be a built-in fungible token (qrc20) with at most 18 decimals, else -32602; an unreadable token is -32603. The amount becomes base units exactly. The gas limit is the call's intrinsic gas. A recipient that holds none of the token yet costs the sender a refundable 0.01 QNC storage deposit, which the approval shows. A transfer to the burn address0000000000000000000eon00000000000000036877022destroys the tokens, and the approval says so, as it does for a token that uses QNet's own name. - Contract call. The extension reads the contract first and refuses an address that holds a
built-in token or no contract (-32602); the in-app browser does not read it and names the contract
in its sheet. The gas limit is the site's
gasLimit, which must leave at least 10,000 fuel above the call's intrinsic gas and stay within 1,000,000, or by default the intrinsic gas plus 200,000 fuel. Fuel the call does not burn is refunded. The approval shows the contract with a warning that the wallet does not know it, the method, the input as hex with its size (and as text when it is readable text), the gas limit and the maximum fee, and that a call moves no QNC. - The approval of every transaction arms its Confirm button 1.5 seconds after the window or sheet was last left untouched.
| Form | Result |
|---|---|
| QNC transfer | {status, from, to, amount, nonce, txHash} |
| Token transfer | {status, from, token, to, amount, nonce, txHash} |
| Contract call | {status, from, contract, method, nonce, txHash} |
statusis'submitted'when a node accepted the transaction and named its copy,'unknown'otherwise (the wallet keeps resending it). It never says that a call succeeded.amountis canonical decimal text (QNC, or the token's units).nonceis decimal text. Withfromit is the transaction's identity: at most one transaction of an account applies at a nonce.txHashis the hash one node gave its copy, ornull. Another node can hold the same signed transaction under another hash, and that copy may be the one that lands. Match a transaction by (from,nonce), not bytxHash.
qnet_getTransactionStatus answers for the connected account only (another from, or a locked
wallet, is 4100) and opens nothing. nonce is decimal text above zero, as a result gives it.
status |
Meaning |
|---|---|
pending |
the account's nonce on chain is still below it, and the wallet still sends a transaction at it |
in_block |
the nonce is used and the nodes the wallet asks agree on the one transaction of from at it; blockHeight and txHash are filled when the nodes report them alike |
unknown |
anything else, including a read that failed |
blockHeight and txHash are null unless status is in_block. In a block does not mean applied:
the network records no outcome, so a call that reverted is also in_block. Confirm a call's effect
from the contract's events or storage. An answer is reused for 3 seconds, and one site may start at
most 20 status reads a minute (more: 4001).
// development/qnet-sdk/README.md
const sent = await wallet.sendTransfer({ to: '4c83bc6f4c20906b81beon31e92ebc6ffccd7b973e10d', amount: '1.5' });
const status = await wallet.getTransactionStatus({ from: sent.from, nonce: sent.nonce });The network admits one transaction per account at a time (the committed nonce + 1), so a page that
sends several transactions sends the next one after the previous one is in_block.
code |
message |
When |
|---|---|---|
| 4001 | User rejected the request |
rejected, window or sheet closed, approval timeout (10 minutes), too many requests from the site waiting, too many status reads |
| 4001 | Too many rejected requests from this site, try again later |
the site is in its rejection pause (Approvals and limits) |
| 4100 | Unauthorized |
the site is not connected, lost its connection while waiting, or asks about another account; in the app also a locked wallet |
| 4200 | Unsupported method |
not a method of this wallet |
| 4900 | Disconnected |
the wallet's side of the page went away |
| -32602 | Invalid params |
params of the wrong shape, an invalid address or amount, a message the wallet will not sign, a token that is not a fungible built-in token, in the extension a call to a token or to no contract |
| -32602 | Unsupported parameter, with data: {reason: 'UNSUPPORTED_PARAM'} |
a contract call that names value or accessList; the only error that carries data |
| -32603 | Internal error |
anything else; a page learns nothing about balances or nodes from it |
An insufficient balance, a node refusal and a network failure all come back as -32603 or as
status: 'unknown': the wallet tells the user, not the page.
| Event | Data | When |
|---|---|---|
accountsChanged |
{qnet, solana}, or {} |
the wallet unlocked or the site was connected; {} when the wallet locked, the site was disconnected or the wallet was removed |
disconnect |
{code: 4900, message: 'Disconnected'} |
the site was disconnected, by the user or by qnet_disconnect, or the wallet was removed |
Events go only to pages of the connected origin. on and removeListener accept only these two
names (anything else throws TypeError).
- One approval is on screen at a time. A site may have at most 3 waiting or shown; the extension holds at most 12 in all, the app 16. Past that the request is 4001.
- After the user rejects or closes an approval, the site opens no other approval for 30 seconds; the third such rejection within 10 minutes makes the pause 10 minutes. Requests that need no approval are still answered.
- Every approval a site opens counts until one of its requests is approved and performed: at most 3 within a minute and 10 within 10 minutes. Past that, requests that need an approval get the pause message.
- The extension answers at most 16 unanswered requests per page connection; more are 4001.
- In the app, a page that is not on screen (another tab, the app in the background) gets 4001 instead of a sheet.
The nodes' public routes allow any origin and read no browser credential (RPC API),
so a page may read public chain data from them with fetch, within the same per-address rate limits as
any caller; their answers are the node's word unless they carry a proof. The routes that answer only
local or genesis callers send no cross-origin headers and take a page's request as an outside one. The
site's own https://aiqnet.io/api/* routes set no cross-origin headers. A page:
- asks the wallet about its own transactions (
qnet_getTransactionStatus), and for anything it signs or sends; - reads other public chain data from a node's public routes, or asks its own server, which reads the
nodes with
@aiqnet/sdk/nodeor plain HTTPS (SDK, RPC API).
@aiqnet/sdk wraps all of the above for a page (SDK):
// development/qnet-sdk/README.md
import { findWallet } from '@aiqnet/sdk';
const wallet = await findWallet(); // null when no QNet wallet is installed
const { qnet } = await wallet.connect(); // the user approves the site once
const sent = await wallet.sendTransfer({ to: '4c83bc6f4c20906b81beon31e92ebc6ffccd7b973e10d', amount: '1.5' });
// (sent.from, sent.nonce) is the transaction's identity; sent.txHash is one node's name for its copy.
const status = await wallet.getTransactionStatus({ from: sent.from, nonce: sent.nonce });sendTokenTransfer({token, to, amount}) and callContract({contract, method, args, gasLimit}) send
the other two forms; every refusal is a QNetProviderError whose providerCode is the wallet's code.
| Rule | Browser extension | In-app browser |
|---|---|---|
| Pages | aiqnet.io and games.aiqnet.io |
any HTTPS page |
qnet_activateNode |
on https://aiqnet.io only |
not offered (4200) |
qnet_claimNodeBalance |
on https://aiqnet.io only |
not offered (4200) |
qnet_unlinkNodeDevice |
on https://aiqnet.io only |
not offered (4200) |
| Locked wallet | the approval window asks for the password first | 4100 at once |
| Confirming | a trusted click on the armed button | the armed button, then the password or the device's biometrics |
| An earlier transaction not yet in a block | Confirm stays off until it is in a block | the user chooses to replace it or to send after it |
| Contract call to a built-in token or to no contract | refused before the window (-32602) | shown and sent; the network does not apply it |
| Largest QNC transfer from a site | amount + fee within the 64-bit range | amount + fee at most 2^53 − 1 nano-QNC (about 9,007,199 QNC) |
| Requests held in all | 12 | 16 |