All pages

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.qubits will 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:

CheckMeaning
sender.id === chrome.runtime.idIt came through this extension's content script
message.channel === "qubits-wallet"It is a wallet message
sender.tab.id is definedIt came from a tab
sender.frameId === 0It 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.

Pagewindow.qubitsRequests
https://qubits.sh/PresentHandled
https://app.qubits.sh/, https://a.b.qubits.sh/PresentHandled
https://qubits.sh:8743/Present4900
A frame inside any of theseAbsent
https://evilqubits.sh/, https://evil.example/Absent
http:// pagesAbsent

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.

Methodrequest formAsks the user
connect()qb_connectOnce per site and account
accounts()qb_accountsNever
chain()qb_chainNever
view(params)qb_viewNever
call(params)qb_callEvery 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 when transaction(id) gives status included and receipt.outcome done. 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.

WrittenTypeBytes
qb1...Address32
nativeThe native token's address, all zero32
5u128, up to 39 digits16, little-endian
u64:5u64, up to 20 digits8, little-endian
deadline:600That many seconds from now, up to 10 digits, as a u64 of milliseconds8, little-endian
"text"UTF-8 with a length in front4 (u32, little-endian), then the text
0xabcdThose bytes. Lower-case hex onlyAs written
args: ["native", QUSD, "1000000000000000000", "950000", "deadline:600"]
LimitValue
One argument32,768 characters
All arguments, encoded16,384 bytes
function^[A-Za-z_][A-Za-z0-9_]{0,63}$, and not init
amountUp 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.

CodeMeaningWhen
4001RefusedThe user pressed Refuse (The user refused.), or closed the window (The user closed the wallet.)
4100Not connectedcall() while the account in use isn't connected to this site. The window checks again before it simulates
4200No such methodrequest() with an unknown method
4300The call would failThe simulation failed and the user pressed Close. message is This call would fail: REASON. Closing the window instead gives 4001
4900Can't do it nowSee below
-32602The request makes no senseSee 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. message is what the node said, such as unknown transaction, or no contract at that address for a view().
  • 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. message is the wallet's or the node's.
  • The background worker dropped the request or gave no answer: no answer, or The wallet is not available.

-32602 means the request failed parseRequest, before anything reached the wallet or the user:

messageCause
expected an objectThe request or its params isn't an object
contract is an addresscontract 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 namefunction doesn't match the pattern
init runs once, at deploymentfunction is init
args is a listargs isn't an array
each argument is a stringAn argument isn't a string, or is too long
the arguments are too longOver 16,384 bytes encoded
N doesn't fit in M bytesA 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 stringBad amount
id is a transaction idNot 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.

Inside the tab
PageHolds nothing
Content scriptTrusts nothing
Outside the tab
Background workerHolds no keys
Approval windowSigns
Every request
It reaches the worker, which goes by what Chrome says about the sender.
window.postMessage, from Page to Content script
runtime.sendMessage, from Content script to Background worker
Checks sender, runs parseRequest
Nobody has to agree
qb_accounts, qb_chain, qb_view, qb_transaction, qb_connect when already connected, and every refusal.
{ result | error }, from Background worker to Content script
window.postMessage, from Content script to Page
The promise settles
The user has to agree
qb_connect from a new site, and qb_call from a connected one.
Keeps the request in session storage
windows.create, from Background worker to Approval window
{ waiting: true }, from Background worker to Content script
Passes nothing to the page
The user decides
tabs.sendMessage, from Approval window to Content script
window.postMessage, from Content script to Page
The promise settles
StageRuns inWhat it does
provider.tsThe page's own worldGives 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.tsThe content script's isolated worldThe 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.tsThe extension's service workerThe 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.tsThe 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
MessageBetweenBody
window.postMessagePage to content script{ channel: "qubits-wallet", to: "wallet", id, request }
chrome.runtime.sendMessageContent script to worker{ channel, id, request }
The reply to that messageWorker to content script{ result }, { error } or { waiting: true }
chrome.tabs.sendMessageApproval window to the tab{ channel, id, result } or { channel, id, error }
window.postMessageContent 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

MomentThe site learns
Page loadThat the extension is installed: window.qubits exists
Any time, unconnectedChain 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() waitingNothing, until it is answered
connect() approvedThe address of the account in use, and nothing about other accounts, the account's name or the node's address
connect() refused4001. The window's message differs for Refuse and for closing it
call() waitingNothing, until it is answered
call() approvedThe transaction id, once submitted
call() would fail4300 and the reason, once the user presses Close
After thatWhile 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.

  1. Check again that the site is connected, and answer 4100 if not.
  2. Run the call through POST /v1/contracts/{address}/view, with caller set to the real account and amount as asked. See the view route. If this fails, answer 4900.
  3. If outcome isn't done, show the failure and stop.
  4. Read what changes: the native change is balance_after minus the current balance, and changesOf(receipt, me) reads the events.
  5. Name each token touched, from the wallet's list or by asking the contract with tokenInfo().
  6. Set the gas from the simulation, and estimate the fee.
  7. Show it, and enable Approve after 900 ms.
  8. On approval, sign and submit from the wallet, record the transaction with the site's origin, and answer { id }.

What the window shows

LineSource
Sitesender.origin, from Chrome
Function nameThe request, after the name check
ContractThe token's name if the wallet knows it, otherwise the address
You pay, you getThe simulation: the native change and changesOf
Fee at mostcallFeeEstimate, the amount paid up front. The rest comes back
LeavesOnly 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.

TopicEffect
transferdata is from, to, amount. Adds the amount if to is the account, subtracts it if from is. Net zero entries are dropped
approvedata 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.

changesOf reads events, so it sees what contracts report. A contract that moves value without a transfer event 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.

outcomeReason shown
revertedThe contract's own text
out_of_gasit ran out of gas. The simulation had 5,000,000 gas, the most a transaction can have
trappedthe contract crashed
invalidthe 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;
}
EventWhat happens
A second request that needs the user, from the same tab4900 at once. A page waits on one such request at a time; other tabs are separate, and reads still work
The user answersanswer() removes the request and sends the reply, so it can be answered only once
The window is closedchrome.windows.onRemoved answers 4001
The tab is closedchrome.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 scripts
  • extension/src/provider.ts: window.qubits
  • extension/src/bridge.ts: the content script
  • extension/src/background.ts: sender checks, routing, ask, window and tab events
  • extension/src/requests.ts: pending requests and answer
  • extension/src/sites.ts: allowedSite, error codes, parseRequest, encodeArgs, changesOf, isUnlimited, gasFor
  • extension/src/popup.ts: showRequest, showConnect, showCallRequest, showSites, APPROVE_AFTER_MS
  • extension/src/wallet.ts: Site, connectSite, disconnectSite, callFeeEstimate, failureOf
  • extension/tests/site.mjs: all of this in real Chrome
  • chain/crates/qubits-execution/src/lib.rs: simulate