A dApp talks to a Tari Ootle wallet through window.tari. The same calls work
whether the user has the Sapient extension or is running your dApp inside the Tari Universe web wallet.
You never detect which wallet you have — you call methods, and feature-detect what they support.
request() overloads<script src="https://universe.tari.mw/tari-connector.js"></script>
Include it always. It supplies the provider when your dApp is embedded in Tari Universe, and stands
aside when an extension already owns window.tari in an ordinary tab.
const [account] = await window.tari.request({ method: "tari_requestAccounts" });
const balances = await window.tari.request({ method: "tari_getBalances" });
const caps = await window.tari.request({ method: "tari_getCapabilities" });
| Method | Params | Returns |
|---|---|---|
tari_requestAccounts | — | string[] — prompts |
tari_getAccounts | — | string[], empty when disconnected. Never prompts |
tari_getNetwork | — | network name; no connection needed |
tari_getWalletAddress | — | bech32m wallet address (otl_…) — address a private output to this, never the component address |
tari_getBalances | — | TokenBalance[] |
tari_getCapabilities | — | WalletCapabilities |
tari_getSubstate | {substateId, version?} | the substate |
tari_getTransactionResult | {transactionId} | the result |
tari_signAndSubmitTransaction | {instructions, maxFee?, inputs?, dryRun?} | prompts unless dryRun |
tari_createTransactionRequest | TransactionRequestOperation | {requestId} — prompts (opens the popup, doesn't block on it) |
tari_getTransactionRequest | {requestId} | {status, note, result?, error?} — poll until not "pending" |
tari_submitTransactionRequest | {requestId} | the operation's result |
tari_requestViewAccess | — | {granted} — prompts, read-only, never authorizes a spend |
tari_getPrivateBalances | — | PrivateBalance[] (needs view access) |
tari_signOwnershipChallenge | {resourceAddress, substateId, challenge} | {publicKey, publicNonce, signature} — prompts. Proves control of one output |
tari_signWalletOwnershipChallenge | {challenge} | {walletAddress, publicNonce, signature} — prompts. Proves control of the address itself, no output involved |
tari_disconnect | — | null |
Both wallets implement everything above, including the transaction-request trio and the private-spend
kinds below — check tari_getCapabilities because accounts can differ (a daemon-relayed
account has no view secret), not because the wallets do.
const caps = await window.tari.request({ method: "tari_getCapabilities" });
if (caps.stealthWithdraw) { /* … */ } else { /* offer the plain path */ }
Fields: exactInputSelection, stealthWithdraw, stealthRedeem, stealthRedeemPrivateFee, htlcFund,
scriptPathSpend, privateSpend, minimumValuePromise,
ownershipProof, walletOwnershipProof, privateBalanceView, privateViewGranted,
transactionResultLookup, transactionRequests, walletAddress,
dryRunIsLocal. If you catch yourself writing if (isSapient), reach for one of these.
Ask for a private spend through tari_createTransactionRequest with
kind: "shield" | "unshield" | "sendPrivately" | "withdrawStealthAndExecute" |
"redeemStealthOutputAndExecute" | "redeemStealthOutputWithPrivateFee" | "htlcFund" | "htlcClaim" | "htlcRefund".
You cannot build a stealth transfer yourself — a raw StealthTransfer
instruction passed as { kind: "instructions" } is rejected outright; it needs a balance proof
and per-input authorizations only the wallet's own signer can produce.
withdrawStealthAndExecute and redeemStealthOutputAndExecute reveal stealth
value onto the transaction's workspace as a Bucket (id 0 — reference it as
{ Workspace: { id: 0, offset: null } }, a plain integer, not a name) and run your own
followUpInstructions against it in the same signed transaction — the difference is only
where the value comes from. withdrawStealthAndExecute draws an amount from the
connected wallet's own tracked private balance, coin-selected by the wallet. redeemStealthOutputAndExecute
spends one specific stealth output — identified by commitmentHex, with its actual
revealedAmount — that some other party minted directly to the connected wallet's address (a
ballot, a ticket, a voucher): there's nothing to coin-select, you already know exactly which output and
how much it's worth. List every other component your followUpInstructions touch in
relatedComponents, or the engine rejects the unregistered substate with SubstateNotFound.
See llms-full.txt for the full type signatures and a worked example.
Both of those pay their fee from the wallet's revealed balance — fine normally, but if
followUpInstructions itself carries something that would deanonymize the wallet if the fee
input did (a voting ballot's ranking, say), a revealed fee defeats the whole point: it signs with the
wallet's ordinary key, linking the transaction to the account just as plainly as an unshielded send would.
redeemStealthOutputWithPrivateFee closes that gap — same as redeemStealthOutputAndExecute,
except the fee is also paid from a second stealth UTXO (feeResourceAddress +
feeCommitmentHex, a stealth output the wallet already owns and can decrypt, e.g. from its own
prior shield). Confirmed live: the resulting transaction's substate diff contains only the two
stealth UTXOs and whatever relatedComponents touch — no trace of the wallet's account address.
maxFee is still revealed publicly as a flat amount (bucket-paid fees have no refund
destination), and the result carries feeChangeCommitment — the fee UTXO's unspent remainder,
which you must track yourself to fund a later call the same way.
shield and sendPrivately accept minimumValuePromise — a public,
permanent claim baked into the new output's own range proof that it's worth at least that
much. That output becomes a self-contained, non-interactively verifiable proof of funds:
// Prove this wallet can cover 100000, without revealing what it actually holds.
const { requestId } = await window.tari.request({ method: "tari_createTransactionRequest", params: {
kind: "shield", resourceAddress, amount: "100000", minimumValuePromise: "100000",
}});
// ...poll tari_getTransactionRequest until approved, then tari_submitTransactionRequest.
// The result carries `substateId` — put that in your link.
// Anyone verifies it — no wallet, no signature, no live session:
const substate = await window.tari.request({ method: "tari_getSubstate", params: { substateId } });
// -> read `minimum_value_promise` off the output, and confirm the substate is still unspent.
Three things to design around: the disclosure is public and permanent for as long as the output exists, not just to whoever the proof was for; it proves one output, not a whole balance (shield the full amount into one output first to prove total spending power); and spending the output destroys the proof — a verifier must re-check the substate is still unspent at the moment they care, not merely that it once existed.
A minimumValuePromise link is a bearer artifact: it proves an output exists
and is unspent, not who is presenting it. Anyone who obtains the substateId — a screenshot, a
forwarded link — can present it identically. Closing that gap needs a signature over a challenge the
verifier generated themselves:
// The verifier makes this up and gives it to the prover through their own channel:
const challenge = "prove-carol-" + new Date().toISOString() + "-" + crypto.randomUUID();
// The prover's wallet signs it:
const { publicKey, publicNonce, signature } = await window.tari.request({
method: "tari_signOwnershipChallenge",
params: { resourceAddress, substateId, challenge },
});
// The verifier checks the response against the substate's OWN on-chain key —
// never against `publicKey` at face value, since anyone can self-report any key:
const substate = await window.tari.request({ method: "tari_getSubstate", params: { substateId } });
const authKey = substate.substate.Utxo.output.auth.Key;
// authKey === publicKey, and (publicNonce, signature) verify against it and `challenge`
// via standard Ristretto/Schnorr verification — see the reference implementation below.
The bytes actually signed are not challenge alone — the wallet wraps it under a domain tag
(com.tari.paylink.ownership_proof) disjoint from real transaction signing
(com.tari.ootle.transaction), which is exactly why there's no way to ask for a raw signature
over an arbitrary message instead: doing that naively risks a signature that's replayable as spend
authorization. A static or reused challenge proves nothing about when — always generate a fresh one
per check.
Working reference implementation, challenge/response UI included, at /paylink — its verify page runs the Ristretto/Schnorr check entirely client-side against public on-chain data, no wallet required to check someone else's proof.
tari_signWalletOwnershipChallenge is the same idea with no output involved — it proves the
connected account holds its own otl_… wallet address, full stop:
const { walletAddress, publicNonce, signature } = await window.tari.request({
method: "tari_signWalletOwnershipChallenge",
params: { challenge: "prove-carol-2026-09-03-8f2a1c" },
});
Signs with the account's own persistent owner key, under its own domain tag
(com.tari.paylink.wallet_ownership_proof) — deliberately different from both real transaction
signing and the per-output proof's domain, so a signature can never be mistaken for the wrong kind
of claim. A verifier decodes the owner key from the address they already have in mind
(parseOotleAddress, no network call needed) and checks against that — never against
walletAddress as returned. Requires capabilities.walletOwnershipProof. The
/paylink page's "Prove you hold a wallet address" / "Check a wallet ownership proof"
widgets are a working example of both sides.
ResourceType (Fungible | NonFungible | Confidential | Stealth) is fixed
per-resource at creation, not a wallet or dApp choice. Everything above works for any
Stealth-type resource, not just XTR — nothing in the API is XTR-specific.
tari_signWalletOwnershipChallenge to attach "and this is verifiably them" to
an otherwise fully public lookup — see /paylink's "Look up public holdings" (no
signature) and "Check a wallet ownership proof" (adds the identity check).minimumValuePromise equivalent for it, and this API surface
doesn't expose deposit/withdraw or balance-decryption for Confidential vaults.await window.tari.request({
method: "tari_signAndSubmitTransaction",
params: { instructions, maxFee: "5000", dryRun: true },
});
dryRun: true never prompts and spends nothing. Use it for pricing that
reprices as the user types; only a real submission asks for approval.
| Code | Meaning | Do |
|---|---|---|
4001 | User rejected | Not an error — return to the pre-connect state quietly |
4100 | Not connected | Call tari_requestAccounts |
4200 | Unsupported here | Feature-detect |
-32603 | Internal / network rejection | Show message — it carries the chain's reason |
Tari Universe runs dApps in a cross-origin iframe. If you send X-Frame-Options or a CSP
that excludes it, the wallet falls back to opening you in a tab. To allow embedding:
Content-Security-Policy: frame-ancestors 'self' https://universe.tari.mw;
otl_esm_1…;
instruction arguments need component_…. Passing the wrong one fails deep in deserialization
as "data did not match any variant of untagged enum TransactionInput", naming neither field nor reason.OneOrMoreInputsNotFound; creating one that exists rejects with
"is already UP and conflicts with an existing output". Check, then create only if absent.Lock failure: Substate …:N is not found or DOWN.Number.MAX_SAFE_INTEGER. Divide by 10 ** divisibility to display; never assume 6.amount is the revealed side,
confidentialAmount the stealth side. A revealed balance of 0 does not mean empty.tari_signOwnershipChallenge against a challenge you generated, verified against the
substate's own auth.Key — never against a public key the other party supplies about
themselves.