Download irondivi-wallet-chrome-v0.0.3.zip from the IronDivi Wallet release page and unzip it. Open chrome://extensions, turn on Developer mode, click Load unpacked and choose the IronDivi-Wallet-chrome folder. It runs in Chrome, Edge and Brave; it is not in any browser store and there is no Firefox build yet. Get testnet wallets walks through the install and a first testnet wallet. Everything below was checked against that release (extension version 0.0.3, source commit 43aa060), and the fact check names the line for every claim. The calls a page makes have not changed since 0.0.2, the release the signing and mainnet runs below were made with.
Already have an older version? 0.0.1 does not work on mainnet: its wallets have testnet addresses only. 0.0.2 fixed that, and 0.0.3 colours the two networks apart, asks for the wallet password to switch between them, and shows incoming payments while they are still pending. Unzip the new release over the same folder, then press Reload on the extension's card in chrome://extensions. Your wallets stay. Do not remove the extension to upgrade: removing it deletes its wallets, and only a recovery phrase brings them back.
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:
- The injected provider (
src/injected/provider.ts) runs in your page. Every call becomes awindow.postMessageof typeIRONDIVI_PROVIDER_MESSAGEwith a random UUID as itsid. - 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 asIRONDIVI_PROVIDER_RESPONSEwith the sameid. - 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 }.
window.irondiviisIronDivi === true'1.0.0'0.0.3The two version numbers are different things: window.irondivi.version is the provider API's own string; 0.0.3 is the extension package in its manifest.
Divi dApps connect through this injected provider, and fall back to the web wallet's DIVI_SIGN_* bridge (section 11). 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:
| Check | Rule |
|---|---|
| Scheme | https: on any host, or http: only on localhost or 127.0.0.1. file:// pages never get the provider. |
| Site allowlist | The 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 any run of characters except a dot, ? matches one character, and case does not matter. So *.divi.domains covers app.irondivi.com, but not bare divi.domains and not a.b.divi.domains. List deeper names exactly. |
So, while you develop, serve your page on http://localhost or http://127.0.0.1. Opening the HTML file straight from disk will not work. One line is enough:
python3 -m http.server 8000 # then open http://localhost:8000/
A user can add your production hostname in the extension under : a text box with one pattern per line, such as yourapp.example or *.yourapp.example, then Save Sites, then reload your page. The allowlist is read again on every message, so removing a site cuts off a page that is already open. Until they do, your page sees no provider, which is the "extension isn't there" case in section 11.
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.
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 at any time under ; your page gets no event when that happens (see Events), so check isConnected() before anything that needs the connection.
If the user presses Reject, connect() throws Request rejected by user; if they close the window, Request rejected: approval window closed. 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 network = await window.irondivi.getNetwork(); // 'testnet' or 'mainnet' const balance = await window.irondivi.getBalance(); // a number, in DIVI, for the whole account const one = await window.irondivi.getBalance('y…'); // just that address, which must be in the account
Balance. The extension asks its RPC backend getaddressbalance for every address in the account, adds the satoshis up and divides by 100,000,000. Pass one of the account's addresses to get that address alone; any other address fails with Address is not in this wallet. The backends are the public Divi RPC proxies, https://app.irondivi.com/api/testnet/rpc/ for testnet and https://app.irondivi.com/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 . Every account has addresses on both networks, from the same recovery phrase on two derivation paths: m/44'/1'/0'/0/i on testnet and m/44'/301'/0'/0/i on mainnet. Switching network keeps the account and swaps in its addresses for that network, so the address connect() gave you belongs to one network only. From 0.0.3, switching asks for the wallet password (the one that unlocks the popup, not the recovery phrase), and the popup is purple on testnet and Divi red on mainnet. A wallet made by 0.0.1 has addresses on the network it was made on only; the first time its owner opens it on the other network, the popup asks for the password once and adds them. Testnet addresses start with x or y (prefix 0x8b); mainnet with D (prefix 0x1e).
getNetwork() resolves to the string 'testnet' or 'mainnet': whatever the wallet is set to right now. If the extension answers without one, it throws Wallet did not report its network. The user can switch at any moment and your page gets no event, so read it again just before you send.
connect()
getBalance() and getAccounts() answer only a connected site. Before connect(), or after the user disconnects you, they fail with Site not connected. Please connect first. getNetwork() and isConnected() work without a 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, picks which of the account's addresses signs: signMessage(message, 'y…'). Leave it out and the account's first address signs, the same one connect() returned. An address that is not in the account fails with Address is not in this wallet. So the pair to verify is the address you asked for, or connect().address, with the 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://app.irondivi.com/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; the node, a Divi Core 3.0.0 behind the proxy, verified it. On 2026-10-23 the released 0.0.1 extension, loaded in Chrome, signed a games.divi.domains challenge through signMessage(), and that signature verified as well. The same day 0.0.2, set to mainnet, signed a message for a page on localhost with a D address, and the mainnet node's verifymessage answered true. On 2026-10-24 0.0.3, on testnet, connected to a page: its approval window named the network, Testnet, in testnet's purple, and connect() returned the testnet address. 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://app.irondivi.com/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://app.irondivi.com/testnet/explorer2/tx/' + txid);
A sendTransaction from a page opens the extension's approval window. It shows your site, To, Amount, Fee and Total; the user types the wallet password and presses Approve, and only then is the transaction signed and broadcast. Reject throws Request rejected by user; closing the window throws Request rejected: approval window closed. Before the window opens, the extension checks the connection, the send_transaction permission (granted at connect), the spending limits and the amount, so those errors come back at once, with no window.
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. Amounts below 0.0000546 DIVI fail with Amount is below the dust threshold of 0.0000546 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.
8Events
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.
9The 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. The one exception is irondivi_getNetwork, which calls getNetwork() and resolves to its string. Unknown names throw Unknown method: ….
| Method | Params | Resolves to |
|---|---|---|
| irondivi_connect | none | { address, connected: true } |
| irondivi_disconnect | none | undefined |
| irondivi_accounts | none | [{ index, address, path }, …]; getAccounts() maps this to addresses |
| irondivi_getBalance | { address? } | { balance } in DIVI |
| irondivi_getNetwork | none | 'testnet' or 'mainnet' |
| irondivi_signMessage | { message, address?, addressIndex? } | { signature } |
| irondivi_sendTransaction | { to, amount, fee?, fromAddresses? } | { txid, fee } |
The raw form of irondivi_signMessage takes an address or an addressIndex: request({ method: 'irondivi_signMessage', params: { message, addressIndex: 2 } }) signs with the account's third address. If you pass both, address wins. The user sees the same approval popup.
10A 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 11'); }, 1000);
$('connect').onclick = async () => {
try {
const { address } = await provider.connect();
log('connected as', address);
const network = await provider.getNetwork();
log('network:', network);
log('balance:', await provider.getBalance(), 'DIVI');
$('sign').disabled = false;
$('send').disabled = 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://app.irondivi.com/testnet/explorer2/tx/' + txid);
} catch (e) { log('send failed:', e.message); }
};
</script>
The Send button stays disabled unless getNetwork() 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.
11If 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:
- Your page opens the wallet in a popup window with
window.openand waits for it to load. - Your page posts
{ type: 'DIVI_SIGN_REQUEST', payload: { message, requestId } }into that window.messageis a non-empty string of at most 4096 characters;requestIda non-empty string of at most 128, which you generate. - The wallet checks
event.originagainst a fixed allowlist, shows the user the message and your origin in its Sign panel, and when the user signs, posts back toevent.sourceeither{ type: 'DIVI_SIGN_RESPONSE', payload: { signature, address, requestId } }or{ type: 'DIVI_SIGN_CANCEL', payload: { requestId, reason } }(reason'user_rejected'by default). - Your page matches the
requestId, checksevent.originis the wallet's andevent.sourceis the window it opened, then verifies the signature exactly as in step 6.
In production the wallet answers only pages on https://app.irondivi.com 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 see | What it means |
|---|---|
window.irondivi is never defined | The 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 origin | Your site is no longer in the allowed-sites list. The extension checks it on every message, so a site removed after the page loaded gets this. Add it back under . |
Not connected. Call connect() first. | Thrown by the provider before it even asks the extension: signMessage and sendTransaction need a successful connect() first. |
Site not connected. Please connect first. | The same check, made by the background, for getBalance, getAccounts, signMessage and sendTransaction. You have not connected yet, or the user disconnected your site under ; call connect() again. |
Request rejected by user | The user pressed Reject in the approval window. Offer to try again; don't loop. |
Request rejected: approval window closed | The user closed the approval window instead of answering. Treat it like Reject. |
Connection request timed out / Request timed out | The popup sat unanswered for 5 minutes (the background's timer). The pending request is dropped. Ask again. |
Request timeout | The 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. |
No account selected | The 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. |
Amount is below the dust threshold of 0.0000546 DIVI | The amount is too small to relay. Send at least 0.0000546 DIVI. |
Address is not in this wallet | The address you passed to getBalance or signMessage is not one of the selected account's addresses. The user may have switched account; call getAccounts() again. |
Site does not have send_transaction permission | The 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… | The user switches network in the popup header; your page cannot. Check getNetwork() (step 5) before every send and refuse to send on the wrong network. |
This wallet has no Mainnet address yet. Open IronDivi Wallet and unlock it once on Mainnet. (or Testnet) | From connect(), signMessage or sendTransaction: the wallet was made by 0.0.1 on the other network and has no addresses on this one yet. The user opens the popup, enters the password once, and tries again. |
The wallet changed network since this request. Ask the site again. | The user switched network while your signing or send request waited for approval, so the extension refused to sign on a network you did not ask for. Read getNetwork() and ask again. |
Wallet did not report its network | getNetwork() got a reply without a network. Reload the extension and the page. |
Unknown method: … | request() only knows the seven irondivi_* names in step 9. |
Unsupported method | Something posted a message to the extension that is not one of the provider's methods. The extension forwards only those; use window.irondivi rather than posting messages yourself. |
Still stuck? Ask the Divi chatbot, or see all tutorials.
Next
- Sign in with Divi: turn step 6 into a login flow.
- Sign and verify a message: the same signature from every wallet's side.
- Talk to a Divi node from Python and JavaScript: everything else the public RPC proxies answer.
- Testnet in the web wallet and the extension and the faucet: coins to test with.