window.qubits
The interface websites use to talk to the browser wallet: its methods, results, events and error codes, which origins may use it, the path of each request, what a site learns and when, and what the user sees before anything is signed. It is written for site developers and for auditors of the wallet's boundary.
Who gets it
For now, only https://qubits.sh and its subdomains, in the top frame, on the default port. Two independent checks enforce this.
window.qubitswill soon work on every website.
The manifest injects the two content scripts only on matching pages, at document_start, and only in the top frame:
{
"matches": ["https://qubits.sh/*", "https://*.qubits.sh/*"],
"js": ["dist/provider.js"],
"run_at": "document_start",
"world": "MAIN"
}
The background worker judges every request by sender, which Chrome fills in and a page can't forge. A request goes on only if all of these hold, and is dropped otherwise:
| Check | Meaning |
|---|---|
sender.id === chrome.runtime.id | It came through this extension's content script |
message.channel === "qubits-wallet" | It is a wallet message |
sender.tab.id is defined | It came from a tab |
sender.frameId === 0 | It came from the top frame |
allowedSite(sender.origin) | The origin matches the pattern below |
const SITE = /^https:\/\/(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)*qubits\.sh$/;
The pattern has no port. The manifest's match patterns name none either, so a page on https://qubits.sh:8743 does get window.qubits, but each of its requests is dropped and answers 4900.
| Page | window.qubits | Requests |
|---|---|---|
https://qubits.sh/ | Present | Handled |
https://app.qubits.sh/, https://a.b.qubits.sh/ | Present | Handled |
https://qubits.sh:8743/ | Present | 4900 |
| A frame inside any of these | Absent | |
https://evilqubits.sh/, https://evil.example/ | Absent | |
http:// pages | Absent |
tests/site.mjs runs each of these in real Chrome.
API
interface Qubits {
readonly isQubits: true;
request(body: { method: string; params?: unknown }): Promise<unknown>;
connect(): Promise<string[]>;
accounts(): Promise<string[]>;
chain(): Promise<{ chain_id: string; genesis_hash: string; height: number }>;
view(params: CallParams): Promise<ViewResult>;
call(params: CallParams): Promise<{ id: string }>;
transaction(id: string): Promise<TransactionResult>;
}
interface CallParams {
contract: string; // qb1...
function: string;
args?: string[]; // default: none
amount?: string; // native tokens in base units, default "0"; call only
}
interface ViewResult {
outcome: Outcome;
output: string; // hex
events: { contract: string; topic: string; data: string }[];
}
interface TransactionResult {
status: "pending" | "included";
block_height: number | null;
receipt: Receipt | null;
}
Outcome and Receipt are the API's: see Receipts. The object is frozen, and window.qubits can't be written or redefined. The script runs at document_start, and does nothing if qubits is already on window.
| Method | request form | Asks the user |
|---|---|---|
connect() | qb_connect | Once per site and account |
accounts() | qb_accounts | Never |
chain() | qb_chain | Never |
view(params) | qb_view | Never |
call(params) | qb_call | Every time |
transaction(id) | qb_transaction, params { id } | Never |
Each method is a thin call to request. await qubits.request({ method: "qb_chain" }) is the same as await qubits.chain(). Promises have no timeout: connect() and call() stay pending for as long as the user reads.
There is no method to sign a message, to send a native transfer, or to read a balance. A site can ask for a contract call and nothing else, and it never receives a key or a signature. To send native tokens, a contract call carries amount.
connect()
Asks to see the account in use. It resolves with one address in a list. If this site and this account are already connected, it resolves at once without asking.
accounts()
Resolves with [address] if the account in use is connected to this site, and [] if not. It never asks, and it works while the wallet is locked. With no account in the wallet at all it rejects with 4900.
chain()
Resolves with chain_id, genesis_hash and height from GET /v1/status on the wallet's node. The user picks that node, so check genesis_hash if a page expects one chain.
view(params)
Reads from a contract, free. It runs as nobody, through the wallet's node, and resolves with outcome, output and events. amount is checked but not used. A view that fails resolves, with the failure in outcome.
call(params)
Requires a connection. It opens the approval window, and resolves with { id } once the transaction has been submitted.
{ id }means the transaction left, not that it worked. It worked whentransaction(id)givesstatusincludedandreceipt.outcomedone. A call that reverts is in a block too.
If the account is a vault, the transaction only queues the call. Its receipt stays null, and the call runs later, in another transaction, after the delay. See Vaults.
transaction(id)
What became of a transaction, by its 64 lower-case hex id. It resolves with status, block_height and receipt. An id the node doesn't know rejects with 4900 and unknown transaction.
Arguments
args is a list of strings, one per argument, the way qubits contract call reads them. They are encoded and joined with nothing between them.
| Written | Type | Bytes |
|---|---|---|
qb1... | Address | 32 |
native | The native token's address, all zero | 32 |
5 | u128, up to 39 digits | 16, little-endian |
u64:5 | u64, up to 20 digits | 8, little-endian |
deadline:600 | That many seconds from now, up to 10 digits, as a u64 of milliseconds | 8, little-endian |
"text" | UTF-8 with a length in front | 4 (u32, little-endian), then the text |
0xabcd | Those bytes. Lower-case hex only | As written |
args: ["native", QUSD, "1000000000000000000", "950000", "deadline:600"]
| Limit | Value |
|---|---|
| One argument | 32,768 characters |
| All arguments, encoded | 16,384 bytes |
function | ^[A-Za-z_][A-Za-z0-9_]{0,63}$, and not init |
amount | Up to 39 digits, below 2^128, as a string |
output, topic and data are hex, and numbers in them are little-endian. deadline: is computed in the background worker when the request arrives, not when the user approves it, so a long read uses up the deadline.
Events
window receives one event, qubits#initialized, a plain Event with no data, when the provider is ready. Your script may run before it, so check window.qubits first and listen as well.
There is no other event. A site isn't told when the user switches account, disconnects it or logs out. Call accounts() to find out.
Errors
A rejected promise holds a QubitsError with a numeric code and a message. Codes follow EIP-1193 where it has one.
| Code | Meaning | When |
|---|---|---|
4001 | Refused | The user pressed Refuse (The user refused.), or closed the window (The user closed the wallet.) |
4100 | Not connected | call() while the account in use isn't connected to this site. The window checks again before it simulates |
4200 | No such method | request() with an unknown method |
4300 | The call would fail | The simulation failed and the user pressed Close. message is This call would fail: REASON. Closing the window instead gives 4001 |
4900 | Can't do it now | See below |
-32602 | The request makes no sense | See below |
4900 covers everything that stops the wallet from answering:
- The wallet has no account:
The wallet has no account yet. - The node can't be reached, or refused a read.
messageis what the node said, such asunknown transaction, orno contract at that addressfor aview(). - Another request from this tab is waiting:
The wallet is already waiting for an answer from this page. - The simulation could not run, for example a
call()to an address with no contract. - Signing or submitting failed after approval.
messageis the wallet's or the node's. - The background worker dropped the request or gave no answer:
no answer, orThe wallet is not available.
-32602 means the request failed parseRequest, before anything reached the wallet or the user:
message | Cause |
|---|---|
expected an object | The request or its params isn't an object |
contract is an address | contract isn't a string |
not a Qubits address: ..., a character is off in ... | contract or an argument isn't a valid address |
function is a name | function doesn't match the pattern |
init runs once, at deployment | function is init |
args is a list | args isn't an array |
each argument is a string | An argument isn't a string, or is too long |
the arguments are too long | Over 16,384 bytes encoded |
N doesn't fit in M bytes | A number too large for its type |
can't read the argument ... | Its shape matches nothing in the table above |
amount is a whole number of base units, as a string | Bad amount |
id is a transaction id | Not 64 lower-case hex characters |
A request is parsed first, so a malformed one gets -32602 even from a wallet with no account.
The path of a request
A request crosses four stages. Each can reach more of the wallet than the one before, and only the last can sign. Time runs downward.
| Stage | Runs in | What it does |
|---|---|---|
provider.ts | The page's own world | Gives the request a number and posts it to the window. When the answer comes back, it matches id and settles the promise. Only messages from the page's own window and origin count, in both directions |
bridge.ts | The content script's isolated world | The only place that can reach both the page and the extension. It trusts nothing it carries, and accepts an answer only from its own extension |
background.ts | The extension's service worker | The door. It holds no keys and signs nothing. It checks sender, runs parseRequest on the page's request, reads the saved wallet and answers what needs nobody's agreement |
popup.ts | The approval window, at popup.html?request=<id> | Reads the kept request, shows it and answers straight to the tab. The worker takes no part, and the window closes |
| Message | Between | Body |
|---|---|---|
window.postMessage | Page to content script | { channel: "qubits-wallet", to: "wallet", id, request } |
chrome.runtime.sendMessage | Content script to worker | { channel, id, request } |
| The reply to that message | Worker to content script | { result }, { error } or { waiting: true } |
chrome.tabs.sendMessage | Approval window to the tab | { channel, id, result } or { channel, id, error } |
window.postMessage | Content script to page | { channel, to: "page", id, result } or { channel, to: "page", id, error } |
provider.ts and bridge.ts have no imports, because each is injected as a single file.
What a site learns, and when
| Moment | The site learns |
|---|---|
| Page load | That the extension is installed: window.qubits exists |
| Any time, unconnected | Chain id, genesis hash and height of the wallet's node. Anything view() and transaction() can read through it. Whether the wallet holds any account: accounts() gives [], or rejects with 4900 if there is none |
connect() waiting | Nothing, until it is answered |
connect() approved | The address of the account in use, and nothing about other accounts, the account's name or the node's address |
connect() refused | 4001. The window's message differs for Refuse and for closing it |
call() waiting | Nothing, until it is answered |
call() approved | The transaction id, once submitted |
call() would fail | 4300 and the reason, once the user presses Close |
| After that | While connected, accounts() keeps giving that address. Balances are public on the chain and readable by anyone with the address |
The approval window
The window opens at 376 by 660 pixels, focused, as a separate popup window, and shows the site's origin as Chrome reported it. If the wallet is locked, it asks for the password of the account in use first. It has no way to log out or to switch account.
Connecting
The window names the site and the account, and says the site will see this address and what it holds. Connect writes a Site record for the origin and the account in use, and answers with the address.
Calling
showCallRequest believes nothing a site says about the effect of a call. It takes only the contract, function, arguments and amount, and asks the chain what they do.
- Check again that the site is connected, and answer
4100if not. - Run the call through
POST /v1/contracts/{address}/view, withcallerset to the real account andamountas asked. See the view route. If this fails, answer4900. - If
outcomeisn'tdone, show the failure and stop. - Read what changes: the native change is
balance_afterminus the current balance, andchangesOf(receipt, me)reads the events. - Name each token touched, from the wallet's list or by asking the contract with
tokenInfo(). - Set the gas from the simulation, and estimate the fee.
- Show it, and enable Approve after 900 ms.
- On approval, sign and submit from the wallet, record the transaction with the site's origin, and answer
{ id }.
What the window shows
| Line | Source |
|---|---|
| Site | sender.origin, from Chrome |
| Function name | The request, after the name check |
| Contract | The token's name if the wallet knows it, otherwise the address |
| You pay, you get | The simulation: the native change and changesOf |
| Fee at most | callFeeEstimate, the amount paid up front. The rest comes back |
| Leaves | Only for a vault: after the vault's delay |
changesOf
export interface Changes {
tokens: Map<Address, bigint>; // by contract: positive comes in, negative goes out
approvals: { token: Address; spender: Address; amount: bigint }[];
}
It walks receipt.events and looks at events whose data is exactly 80 bytes: two addresses and a little-endian u128.
| Topic | Effect |
|---|---|
transfer | data is from, to, amount. Adds the amount if to is the account, subtracts it if from is. Net zero entries are dropped |
approve | data is owner, spender, amount. Recorded as a permission when the owner is the account and the amount is above zero |
An approval gets the wallet's warning mark: Lets qb1... spend AMOUNT. Only if you trust it. An amount of 2^100 or more (isUnlimited) reads as all your tokens, now and later, because no real balance reaches it.
changesOfreads events, so it sees what contracts report. A contract that moves value without atransferevent shows no token change, and the simulation is as good as the node that ran it. The native change comes from the balance itself. Only governance deploys today, which bounds the first risk. See Contracts.
Why a failed simulation can't be approved
A contract that fails is still included and still pays, so approving one could only cost the fee, and a failed run has no changes to show. The window shows This would fail, so nothing was sent. with the reason, and its only control is Close, which answers 4300. There is no Approve button to reach.
outcome | Reason shown |
|---|---|
reverted | The contract's own text |
out_of_gas | it ran out of gas. The simulation had 5,000,000 gas, the most a transaction can have |
trapped | the contract crashed |
invalid | the contract has no such function |
If the account can't cover the fee after the call's own effect, the window shows Not enough coins for the fee. and again offers no Approve button.
The delayed button
Approve and Connect start disabled and turn on after 900 ms (APPROVE_AFTER_MS). The window may open under the mouse, and the delay stops a click meant for the page from landing on it.
Gas
A site gives no gas. The wallet takes it from the simulation, with room for state to move a little before the transaction runs:
function gasFor(used: number, most: number): number {
return Math.min(most, Math.ceil(used * 1.25) + 20_000);
}
most is 5,000,000. A swap that used 519,033 gas gets a limit of 668,792, and the transaction on a local test chain showed that value. What goes unused comes back.
Pending requests
A request that waits for the user lives in chrome.storage.session under site-requests, not in the worker's memory, because Chrome stops the worker while a person reads. Content scripts can't read that store.
interface PendingRequest {
id: string; // crypto.randomUUID()
origin: string;
tabId: number;
pageId: unknown; // the page's own number for this request
method: "qb_connect" | "qb_call";
call?: ContractRequest; // parsed: args already encoded as hex
windowId?: number;
}
| Event | What happens |
|---|---|
| A second request that needs the user, from the same tab | 4900 at once. A page waits on one such request at a time; other tabs are separate, and reads still work |
| The user answers | answer() removes the request and sends the reply, so it can be answered only once |
| The window is closed | chrome.windows.onRemoved answers 4001 |
| The tab is closed | chrome.tabs.onRemoved drops the request and closes its window, with nothing approved |
Only closing the tab drops a request. After a reload the window stays open, its answer reaches a page that no longer waits for it, and the tab stays blocked until the window is dealt with.
Connected sites
Saved.sites lists { origin, address, at } records. A connection is one origin and one account, so switching to another account hides the site's view until that account connects too. accounts() then gives [], and call() rejects with 4100.
To revoke, open Settings, then Sites, and press Disconnect on the site. The list shows the account in use. The site gets no notice, but from then on accounts() gives [] and call() rejects with 4100.
Log out doesn't disconnect anything. A connected site stays connected, and its next call() opens a window that asks for the password.
A complete example
A page that connects, quotes a swap on the AMM, asks the user to approve it, and reports what happened. AMM and QUSD are contract addresses. See Standard contracts for quote and swap.
const AMM = "qb1...";
const QUSD = "qb1...";
const ONE_TOKEN = "1000000000000000000"; // the native token has 18 decimals
/** The provider may arrive just after this script. */
function wallet(): Promise<Qubits> {
return new Promise((resolve, reject) => {
if (window.qubits) return resolve(window.qubits);
addEventListener("qubits#initialized", () => resolve(window.qubits!), { once: true });
setTimeout(() => reject(new Error("no wallet, or not on a qubits.sh address")), 1_000);
});
}
/** Numbers in `output` are little-endian hex. */
const littleEndian = (hex: string) =>
[...hex.match(/../g)!].reverse().reduce((n, byte) => n * 256n + BigInt(`0x${byte}`), 0n);
const text = (hex: string) =>
new TextDecoder().decode(Uint8Array.from(hex.match(/../g)!, (byte) => parseInt(byte, 16)));
async function untilIncluded(qubits: Qubits, id: string) {
for (;;) {
const tx = await qubits.transaction(id);
if (tx.status === "included") return tx;
await new Promise((done) => setTimeout(done, 500));
}
}
async function swap() {
const qubits = await wallet();
// asks once per site and account; later visits find the address already there
const [known] = await qubits.accounts();
if (!known) await qubits.connect();
// free, and asks nobody
const quote = await qubits.view({
contract: AMM,
function: "quote",
args: ["native", QUSD, ONE_TOKEN],
});
if (quote.outcome !== "done") throw new Error("no such pool");
// accept 1% less than quoted
const least = (littleEndian(quote.output) * 99n) / 100n;
// opens the approval window; the simulation decides what it shows
const { id } = await qubits.call({
contract: AMM,
function: "swap",
args: ["native", QUSD, ONE_TOKEN, least.toString(), "deadline:600"],
amount: ONE_TOKEN,
});
// `id` means it left. The receipt says whether it worked.
const { block_height, receipt } = await untilIncluded(qubits, id);
const outcome = receipt?.outcome;
if (outcome === "done") return `Swapped in block ${block_height}.`;
if (typeof outcome === "object") return `Reverted: ${text(outcome.reverted.reason)}.`;
return `Failed: ${outcome ?? "no receipt yet, the account may be a vault"}.`;
}
swap().then(console.log).catch((error) => {
switch (error.code) {
case 4001: return console.log("Refused.");
case 4100: return console.log("Connect first.");
case 4300: return console.log(error.message); // This call would fail: ...
case 4900: return console.log(`Try again: ${error.message}`);
case -32602: return console.error("A bug in this page:", error.message);
default: return console.error(error);
}
});
On a local test chain, quote for one native token gives 2e360f00000000000000000000000000, so littleEndian reads 996,910 and least comes to 986,940, in base units of qUSD. text decodes a reason of 62616c616e636520746f6f206c6f77 as balance too low.
Trying a site locally
A test site has to be a real subdomain over https, since the checks above are the point. Map a name such as dev.qubits.sh to 127.0.0.1 in /etc/hosts, serve it with a certificate Chrome accepts, on port 443, and point the wallet's node at a local chain in Settings. tests/site.mjs does the same with a throwaway certificate.
Source
extension/manifest.json: content scriptsextension/src/provider.ts:window.qubitsextension/src/bridge.ts: the content scriptextension/src/background.ts:senderchecks, routing,ask, window and tab eventsextension/src/requests.ts: pending requests andanswerextension/src/sites.ts:allowedSite, error codes,parseRequest,encodeArgs,changesOf,isUnlimited,gasForextension/src/popup.ts:showRequest,showConnect,showCallRequest,showSites,APPROVE_AFTER_MSextension/src/wallet.ts:Site,connectSite,disconnectSite,callFeeEstimate,failureOfextension/tests/site.mjs: all of this in real Chromechain/crates/qubits-execution/src/lib.rs:simulate