Tutorial · Developers · Preview

The Browser Extension API (preview)

How a web page talks to the IronDivi browser extension through window.irondivi: detect it, connect, read the balance, sign a message, send a testnet transaction, sign a raw transaction.

About 20 minutes reading · Read from the extension source on 2026-10-21 · The extension cannot be installed yet, so nothing on this page has been exercised against a shipped extension.
This is a preview of an API you cannot use today

The IronDivi Wallet extension (manifest version 0.0.1) is not in any browser store and has no public download. Everything below was read from its source code, file by file, and the fact check names the line for every claim. When the extension ships, this page will be re-run against it. Until then, treat it as a specification you can code against, not a working example.

1What the provider is

The IronDivi Wallet extension holds the user's keys. Your page never sees them. Instead the extension injects a small object, window.irondivi, into pages it trusts. Your page calls methods on that object; the extension asks the user to approve in its own popup window; the answer comes back as a resolved promise. It is the same shape as window.ethereum for Ethereum dApps, and the source says so in its first comment.

Under the hood, three pieces talk to each other:

  1. The injected provider (src/injected/provider.ts) runs in your page. Every call becomes a window.postMessage of type IRONDIVI_PROVIDER_MESSAGE with a random UUID as its id.
  2. The content script (src/content/content-script.ts) sees that message, forwards it to the extension's background worker with your page's origin attached, and posts the reply back as IRONDIVI_PROVIDER_RESPONSE with the same id.
  3. The background service worker (src/background/service-worker.ts) checks the origin, checks that your site is connected, opens the approval popup where needed, signs, and answers { success, data, error }.
Object
window.irondivi
Marker
isIronDivi === true
Provider version
'1.0.0'
Extension version
0.0.1

The two version numbers are different things: window.irondivi.version is the provider API's own string; 0.0.1 is the extension package in its manifest.

Divi's own connection mechanism, not WalletConnect

Divi dApps connect through this injected provider, and fall back to the web wallet's DIVI_SIGN_* bridge (section 12). There is no third-party relay, no vendor account and no WalletConnect. That is a recorded decision, ADR 0001 (docs/decisions/0001-no-walletconnect.md in the divi-infrastructure and extension repositories), and this repository carries a test (scripts/check-no-walletconnect.sh) that fails the build if it comes back.

2Where the provider appears

The extension does not inject into every page. The content script checks two things before it adds window.irondivi, and both must pass:

CheckRule
Schemehttps: on any host, or http: only on localhost or 127.0.0.1. file:// pages never get the provider.
Site allowlistThe hostname must match a pattern in the extension's allowed sites setting. The default list is *.divi.domains, localhost, 127.0.0.1. In a pattern, * matches anything except a dot and ** matches anything, so *.divi.domains covers services.divi.domains but not a.b.divi.domains.

So, while you develop, serve your page on http://localhost. Opening the HTML file straight from disk will not work. One line is enough:

python3 -m http.server 8000      # then open http://localhost:8000/
Use localhost, not 127.0.0.1

The content script injects on both, but the background worker's own origin check accepts plain http only for the hostname localhost. On http://127.0.0.1 the provider appears, and then connect() fails with Invalid origin.

A user can add your production hostname to the allowlist in the extension's settings; a pattern like *.divi.domains or yourapp.example. Until they do, your page sees no provider, which is the "extension isn't there" case in section 12.

3Detect it

The content script runs at document_start, but it has to read the allowlist from extension storage before it injects, so your script may run first. The provider fires a irondivi#initialized event on window as soon as it is attached, and fires it again on DOMContentLoaded for listeners that registered late. Handle both orders:

function getProvider() {
  const p = window.irondivi;
  return p && p.isIronDivi === true ? p : null;
}

// Already there?
let provider = getProvider();

// Or arriving shortly.
window.addEventListener('irondivi#initialized', () => {
  provider = getProvider();
});

Give up after a short wait rather than never. The NFT marketplace's bridge waits 500 ms for the extension before it falls back to the web wallet.

Checking isIronDivi matters

The older PrivateDivi extension injected window.pdivi with isPrivateDivi: true. If you support both, prefer window.irondivi when both are present; that is what the marketplace does.

4Connect and get the address

connect() asks the extension to link your origin to the user's selected account. The extension opens a 400 × 600 approval popup; the user presses Approve or Reject. The promise resolves to an object with the account's first address:

const { address } = await window.irondivi.connect();
// address: 'y…' on testnet, 'D…' on mainnet

const connected = await window.irondivi.isConnected();   // true
const accounts  = await window.irondivi.getAccounts();   // ['y…', 'y…', …] every address in the account

await window.irondivi.disconnect();   // forgets this origin

What you actually get back is { address, connected: true }. The provider's own TypeScript type on connect() also lists an optional publicKey, but nothing in the extension fills it in; do not rely on it.

Approval is remembered per origin and per account, in the extension's storage, with two permissions: sign_message and send_transaction. The next connect() from the same origin resolves at once, with no popup. The user can revoke a site from the extension's settings at any time; your page gets no event when that happens (see Events), so check isConnected() before anything that needs the connection.

If the user rejects, connect() throws Connection rejected by user. If the popup sits for five minutes, it throws Connection request timed out. If no account exists in the extension yet, it throws No account selected.

5Read the network and the balance

const balance = await window.irondivi.getBalance();   // a number, in DIVI, for the whole account
const network = await window.irondivi.getNetwork();   // see below

Balance. The extension asks its RPC backend getaddressbalance for every address in the account, adds the satoshis up and divides by 100,000,000. The optional address argument is sent along but the background ignores it: you always get the account total. The backends are the public Divi RPC proxies, https://services.divi.domains/api/testnet/rpc/ for testnet and https://services.divi.domains/api/rpc/ for mainnet; see JSON-RPC from Python and JavaScript for what else they answer.

Network. The extension starts on testnet and the user switches from the badge in the popup header or Settings → Network. Each network has its own accounts, because the derivation path differs: m/44'/1'/0'/0/i on testnet and m/44'/301'/0'/0/i on mainnet. Testnet addresses start with x or y (prefix 0x8b); mainnet with D (prefix 0x1e).

In this source, getNetwork() always answers 'testnet'

The provider reads settings.network from the GET_SETTINGS reply, but the background wraps that reply one level deeper, as { settings: { network, … } }. The lookup finds nothing and the fallback, 'testnet', is returned on both networks. Until that is fixed, read the network through the raw form instead:

const { settings } = await window.irondivi.request({ method: 'irondivi_getNetwork' });
console.log(settings.network);   // 'mainnet' or 'testnet'

Or simply look at the first character of the address connect() gave you.

Balance and addresses do not need connect()

Only connect, isConnected and disconnect are origin-gated, and only signing and sending check that the site is connected. getBalance(), getAccounts() and the settings read answer any page the provider was injected into. For a user that means any allowlisted site can read their addresses and balance silently; for you it means these calls work before, and after, the connection.

6Sign a message, then verify it on your server

This is the building block of sign-in with Divi: your server hands out a one-time text, the user signs it, and the server checks the signature against the address. Only a connected site may ask, and every request is approved in the popup with the wallet password.

const message   = 'Sign in to example.com, nonce 8f3a…, 2026-10-21T10:00Z';
const signature = await window.irondivi.signMessage(message);
// a base64 string, 88 characters, for example:
// IFSojv2ii5slKIkNnRcdQ4ythbUA5vGcvcSs014+aEkTa5ig6oiyh9xAhFfSFcEJQJ3ViKFEI7++9oxgoZkEOec=

Before it posts the request, signMessage calls isConnected() and throws Not connected. Call connect() first. if you skipped step 4. The second argument, address, is accepted but not sent: the extension always signs with address index 0, the same address connect() returned. So the pair to verify is always (connect().address, signature).

The signature is the ordinary Divi message signature: the text is prefixed with "DarkNet Signed Message:\n", double-SHA256'd and signed with a recoverable key, 65 bytes, base64. That is exactly what the C++ node's signmessage makes and what verifymessage checks, on the C++ node and on the IronDivi node alike. So your server needs no crypto library, only one RPC call to a node. The public testnet proxy will do:

curl -s https://services.divi.domains/api/testnet/rpc/ \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"verifymessage",
       "params":["y7QrsJED7LFSkGjTbY9NkknQjdBGcTv1w2",
                 "IFSojv2ii5slKIkNnRcdQ4ythbUA5vGcvcSs014+aEkTa5ig6oiyh9xAhFfSFcEJQJ3ViKFEI7++9oxgoZkEOec=",
                 "I own this address: tutorial 74"]}'

{"error":null,"id":1,"result":true}

That is a real call we made on 2026-10-21. The signature was produced by running the extension's own signing code (src/lib/signer.ts) with a throwaway testnet key, since the extension itself cannot be installed; the node, a Divi Core 3.0.0 behind the proxy, verified it. Change one character of the message and the answer is false. The node answers Invalid address for a bad address and Malformed base64 encoding for a bad signature.

In Node.js on your server, the same call:

async function verify(address, signature, message) {
  const r = await fetch('https://services.divi.domains/api/testnet/rpc/', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'verifymessage',
                           params: [address, signature, message] }),
  });
  return (await r.json()).result === true;
}

Put a nonce and a time in every message you ask the user to sign, and never accept a signature over a message you did not generate. Sign and verify a message covers the same signature from the wallets' side.

7Send a testnet transaction

sendTransaction builds, signs and broadcasts a payment from the user's account and resolves to the txid:

const txid = await window.irondivi.sendTransaction({
  to: 'yJehBfdK9KbrwPRjYrzkrPES9uG5nFLjY4',   // the testnet faucet's return address
  amount: 1.5,                                 // DIVI, not satoshis
  // fee: 0.00005,                             optional, in DIVI; this is the default
  // fromAddresses: ['y…'],                    optional: spend only from these
});
console.log('https://services.divi.domains/testnet/explorer2/tx/' + txid);
No popup: this one is approved by the connection, not per transaction

Unlike connect(), signMessage() and signRawTransaction(), a sendTransaction from a page does not open the approval window. The background checks that your origin is connected and holds send_transaction permission (granted at connect), applies the spending limits, and then signs with the wallet's current session. If the user has typed their password in the popup within the last five minutes (the authTimeoutMinutes default), the coins go out immediately. If not, the call fails with Not authenticated and nothing is sent. Design for that: tell the user to unlock the extension, then retry.

The default spending limit is 10% of the account balance per transaction. Above it the call fails with Transaction exceeds 10% of balance (max: … DIVI); an absolute limit, if the user set one, fails with Transaction exceeds maximum amount limit of … DIVI. Keep your test amounts small. Get testnet coins from the faucet into the extension first (testnet in the extension and web wallet shows how), and send what you don't need back to yJehBfdK9KbrwPRjYrzkrPES9uG5nFLjY4.

8Sign a raw transaction

For anything sendTransaction can't express (multiple recipients, an OP_RETURN, a multisig you are co-signing, an atomic swap) build the transaction yourself and ask the extension to sign only the inputs that belong to the user. It returns signed hex and never broadcasts; you decide when and whether to send it. Raw transactions and multisignature shows how to build the hex.

const { hex, signedIndexes } = await window.irondivi.signRawTransaction(
  txHex,                        // the unsigned (or partly signed) transaction, hex
  [                             // one entry per input, in input order
    { txid: '…', vout: 0, amount: 12.5, scriptPubKey: '76a914…88ac' },
  ],
  [0],                          // which input indexes to sign
  0x01                          // hashType; default 1 = SIGHASH_ALL
);

9Events

function onConnect({ address }) { console.log('connected as', address); }
window.irondivi.on('connect', onConnect);
window.irondivi.on('disconnect', () => console.log('disconnected'));
window.irondivi.off('connect', onConnect);

Two events exist, connect (with { address }) and disconnect (no argument). They fire only when your page's own connect() or disconnect() call succeeds. Nothing fires when the user revokes the site in the popup, switches account or network, or locks the wallet. A listener that throws is caught and logged; it won't break the others.

10The request() form

Like EIP-1193 providers, window.irondivi.request({ method, params }) takes a method name and passes params through untouched. It skips the convenience methods' extras: no isConnected() pre-check, no connect event, no unwrapping of the reply. Unknown names throw Unknown method: ….

MethodParamsResolves to
irondivi_connectnone{ address, connected: true }
irondivi_disconnectnoneundefined
irondivi_accountsnone[{ index, address, path }, …]; getAccounts() maps this to addresses
irondivi_getBalancenone used{ balance } in DIVI
irondivi_getNetworknone{ settings: { network, authTimeoutMinutes, currency, hideBalance, allowedSites, transactionLimits } }
irondivi_signMessage{ message, addressIndex? }{ signature }
irondivi_sendTransaction{ to, amount, fee?, fromAddresses? }{ txid, fee }
irondivi_signRawTransaction{ tx, prevouts, indexes, hashType }{ hex, signedIndexes }

Note one difference from signMessage(): the raw form lets you pass addressIndex, so request({ method: 'irondivi_signMessage', params: { message, addressIndex: 2 } }) signs with the account's third address. The user sees the same approval popup.

11A complete minimal page

Save this as index.html, serve it with python3 -m http.server 8000, open http://localhost:8000/. No build step, no dependencies. It detects the provider, connects, shows the balance, signs a message and sends a small testnet payment.

<!DOCTYPE html>
<meta charset="utf-8">
<title>IronDivi provider demo</title>
<button id="connect" disabled>Connect</button>
<button id="sign" disabled>Sign a message</button>
<button id="send" disabled>Send 0.1 testnet DIVI to the faucet</button>
<pre id="out">Looking for window.irondivi …</pre>

<script>
const out = document.getElementById('out');
const log = (...a) => { out.textContent += '\n' + a.join(' '); };
const $ = (id) => document.getElementById(id);
let provider = null;

function found() {
  if (provider || !(window.irondivi && window.irondivi.isIronDivi)) return;
  provider = window.irondivi;
  log('provider found, version', provider.version);
  $('connect').disabled = false;
  provider.on('connect', ({ address }) => log('event: connect', address));
  provider.on('disconnect', () => log('event: disconnect'));
}
found();
window.addEventListener('irondivi#initialized', found);
setTimeout(() => { if (!provider) log('no extension after 1 s: see section 12'); }, 1000);

$('connect').onclick = async () => {
  try {
    const { address } = await provider.connect();
    log('connected as', address);
    const { settings } = await provider.request({ method: 'irondivi_getNetwork' });
    log('network:', settings.network);
    log('balance:', await provider.getBalance(), 'DIVI');
    $('sign').disabled = false;
    $('send').disabled = settings.network !== 'testnet';
  } catch (e) { log('connect failed:', e.message); }
};

$('sign').onclick = async () => {
  const message = 'demo login ' + crypto.randomUUID() + ' ' + new Date().toISOString();
  try {
    const signature = await provider.signMessage(message);
    log('signature:', signature);
    log('verify with: verifymessage <address> <signature> "' + message + '"');
  } catch (e) { log('sign failed:', e.message); }
};

$('send').onclick = async () => {
  try {
    const txid = await provider.sendTransaction({
      to: 'yJehBfdK9KbrwPRjYrzkrPES9uG5nFLjY4', amount: 0.1,
    });
    log('sent: https://services.divi.domains/testnet/explorer2/tx/' + txid);
  } catch (e) { log('send failed:', e.message); }
};
</script>

The Send button stays disabled unless the network read says testnet, so the demo cannot spend real DIVI by accident. In the real thing, verify the signature on your server (step 6) rather than printing it.

12If the extension isn't there: the web wallet bridge

The IronDivi web wallet (/testnet/wallet/, /wallet/) can sign a message for another page through postMessage, with no extension involved. This is Divi's second connection mechanism, the fallback ADR 0001 names for the NFT marketplace. It signs messages only; it does not send transactions. The protocol, from divi-wallet/lib/messaging.js:

  1. Your page opens the wallet in a popup window with window.open and waits for it to load.
  2. Your page posts { type: 'DIVI_SIGN_REQUEST', payload: { message, requestId } } into that window. message is a non-empty string of at most 4096 characters; requestId a non-empty string of at most 128, which you generate.
  3. The wallet checks event.origin against a fixed allowlist, shows the user the message and your origin in its Sign panel, and when the user signs, posts back to event.source either { type: 'DIVI_SIGN_RESPONSE', payload: { signature, address, requestId } } or { type: 'DIVI_SIGN_CANCEL', payload: { requestId, reason } } (reason 'user_rejected' by default).
  4. Your page matches the requestId, checks event.origin is the wallet's and event.source is the window it opened, then verifies the signature exactly as in step 6.
The allowlist is short

In production the wallet answers only pages on https://services.divi.domains and https://vps1.divi.domains. The localhost and 127.0.0.1 dev origins on ports 3000, 5173 and 8080 are accepted only when the wallet itself is running on localhost. A request from any other origin is dropped with a console warning and no reply, so your page must time out on its own. The extension's allowlist, by contrast, lives in the user's settings and they can extend it.

Both mechanisms are described, with the reasons, in ADR 0001 (docs/decisions/0001-no-walletconnect.md). The order a dApp should try is: injected provider, then the web wallet bridge, then letting the user paste a signature they made by hand in any wallet (sign and verify).

If something goes wrong

What you seeWhat it means
window.irondivi is never definedThe page is not on an allowed origin: it must be https, or http://localhost / http://127.0.0.1, and the hostname must match the extension's allowed-sites list (default *.divi.domains, localhost, 127.0.0.1). file:// never works. Also make sure you listen for irondivi#initialized rather than checking once at load.
Invalid originThe background accepts plain http only for localhost. You are probably on http://127.0.0.1; use http://localhost instead.
Not connected. Call connect() first.Thrown by the provider before it even asks the extension: signMessage, signRawTransaction and sendTransaction need a successful connect() first.
Site not connected. Please connect first.The same check, made by the background. The user may have revoked your site in the popup since you connected; call connect() again.
Connection rejected by user / Request rejected by userThe user pressed Reject in the popup. Offer to try again; don't loop.
Connection request timed out / Request timed outThe popup sat unanswered for 5 minutes (the background's timer). The pending request is dropped. Ask again.
Request timeoutThe provider's own 5-minute timer in your page expired with no reply at all: the content script or background never answered. Reload the page; if the extension was just updated, reload it too.
Wallet lockedThe wallet locked while your request was pending; every pending request is rejected with this. Ask again once the user unlocks.
Not authenticatedsendTransaction found no unlocked session. The user must enter their password in the extension popup (it stays unlocked for authTimeoutMinutes, default 5), then your page retries.
No account selectedThe extension has no wallet yet, or none selected. The user creates or picks one in the popup.
Transaction exceeds 10% of balance (max: … DIVI)The default per-transaction limit. Send less, or the user raises the limit in Settings.
Site does not have send_transaction permissionThe connection exists but without that permission. Today connect() always grants both permissions, so this only appears after the stored connection was edited.
Wrong network: D… address when you expected y…, or getNetwork() says 'testnet' while the user is on mainnetThe user switches network in the popup header; your page cannot. Do not trust getNetwork() in this source (step 5); read request({ method: 'irondivi_getNetwork' }).settings.network or the address prefix, and refuse to send on the wrong network.
Input N is not owned by this accountsignRawTransaction: an index you asked it to sign spends a prevout that does not pay one of the account's addresses.
Unknown method: …request() only knows the eight irondivi_* names in step 10.

Still stuck? Ask the Divi chatbot, or see all tutorials.


Next