dApp integration

One interface,
every Tari wallet.

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.

For your agentSKILL.md · llms.txt · llms-full.txt
For your editortari-dapp.d.ts — typed request() overloads
Connectortari-connector.js — one script tag

Start here

<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" });

Methods

MethodParamsReturns
tari_requestAccountsstring[]prompts
tari_getAccountsstring[], empty when disconnected. Never prompts
tari_getNetworknetwork name; no connection needed
tari_getWalletAddressbech32m wallet address (otl_…) — address a private output to this, never the component address
tari_getBalancesTokenBalance[]
tari_getCapabilitiesWalletCapabilities
tari_getSubstate{substateId, version?}the substate
tari_getTransactionResult{transactionId}the result
tari_signAndSubmitTransaction{instructions, maxFee?, inputs?, dryRun?}prompts unless dryRun
tari_createTransactionRequestTransactionRequestOperation{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_getPrivateBalancesPrivateBalance[] (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_disconnectnull

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.

Feature-detect, never wallet-detect

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.

Private spends & proof of funds

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.

Ownership proofs — who's showing you the link

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.

Proving you hold a wallet address, generically

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.

Resource types — what "private" means depends on the resource

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.

Quotes are free

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.

Errors

CodeMeaningDo
4001User rejectedNot an error — return to the pre-connect state quietly
4100Not connectedCall tari_requestAccounts
4200Unsupported hereFeature-detect
-32603Internal / network rejectionShow message — it carries the chain's reason

Being embeddable

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;

Pitfalls that cost real time