diff --git a/packages/TBC777/src/tbc777.ts b/packages/TBC777/src/tbc777.ts
index ba6775d8f..aba545a22 100644
--- a/packages/TBC777/src/tbc777.ts
+++ b/packages/TBC777/src/tbc777.ts
@@ -83,8 +83,8 @@ export type ClaimAmountEntry = [Id, Amount]
* the specific `escrowRev` revision supplied to `audit()`. Escrow
* implementations should record final-withdrawal entries only in their
* terminal (latest) revision. The `finalWithdraw()` method on tokens
- * additionally verifies that the supplied revision is currently the live tip
- * of the escrow via `computer.last(rev)`.
+ * additionally verifies the tip via `computer.last(rev)` (requires that tip
+ * to be spent in a **confirmed** transaction — see `finalWithdraw`).
*
* SECURITY INVARIANT: Even if an escrow is buggy or malicious and
* over-authorizes claims, the audited balance for any token lineage can never
@@ -492,11 +492,15 @@ export class TBC777 extends TBC20 {
/**
* Claim a final withdrawal from the given escrow revision.
*
- * Performs an explicit terminal-revision check via `computer.last(rev)`. The
- * supplied revision must be the current live tip of the escrow at the moment
- * of evaluation. This check is intentionally omitted from regular
- * `withdraw()` and `getBalance()` so those paths stay free of transient
- * observations and remain deterministic under chain extension.
+ * Performs an explicit terminal-revision check via `computer.last(rev)`.
+ * InnerComputer only returns a definite `last` when the tip is **spent in a
+ * confirmed transaction** (mempool-only or unspent tips invalidate). Typical
+ * flow: record `finalWithdraws` on the tip, `delete` that tip UTXO, wait for
+ * confirmation, then `finalWithdraw(tipRev)`.
+ *
+ * This check is intentionally omitted from regular `withdraw()` and
+ * `getBalance()` so those paths stay free of transient observations and
+ * remain deterministic under chain extension.
*/
async finalWithdraw(rev: Rev) {
return this._withdraw(rev, true)
diff --git a/packages/TBC777/src/tbc777m.ts b/packages/TBC777/src/tbc777m.ts
index 154c7fdb5..fb9a27463 100644
--- a/packages/TBC777/src/tbc777m.ts
+++ b/packages/TBC777/src/tbc777m.ts
@@ -207,9 +207,12 @@ export class TBC777M extends TBC20 {
* Returns the amount this specific token instance (`_id`) is allowed to
* withdraw according to the escrow's `finalWithdraws` list. The amount is
* only returned if the supplied `rev` is the final (last) revision of the
- * escrow (checked via `computer.last(rev)`); otherwise returns `0n`. Only
- * matching entries for the token’s root and id are summed. Intended for
- * one-time final payouts (e.g. winner-takes-all).
+ * escrow (checked via `computer.last(rev)`).
+ *
+ * Note: InnerComputer `last` invalidates the transition when the tip is still
+ * unspent or only spent in the mempool. Callers must use a tip whose spend is
+ * confirmed (same protocol as TBC777 `finalWithdraw`). When `last` succeeds
+ * but points at a different rev, this returns `0n`.
*/
static async computeFinalWithdraw(rev: string, _id: string, _root: string): Promise {
if ((await computer.last(rev)) !== rev) return 0n
diff --git a/packages/TBC777/test/tbc777.test.ts b/packages/TBC777/test/tbc777.test.ts
index 1ec56a8ab..e395d3538 100644
--- a/packages/TBC777/test/tbc777.test.ts
+++ b/packages/TBC777/test/tbc777.test.ts
@@ -67,6 +67,8 @@ describe('TBC777 - Programmable Escrow Token (No-Inflation Focus)', () => {
export ${escrowSource}
export ${tbc777Source}
`)
+ // Confirm module so InnerComputer.load(mod) (semantic isEqualTo) is stable.
+ await minter.db.wallet.restClient.mine(1)
await ensureFunds(minter, 20e8)
@@ -336,13 +338,16 @@ describe('TBC777 - Programmable Escrow Token (No-Inflation Focus)', () => {
// Tip is still live/unspent → computer.last(firstRev) returns undefined →
// InnerComputer invalidates with the framework non-existent-state message.
// (The domain "can only be claimed from last revision" message only appears
- // after the tip has been deleted, when last() returns a concrete tip rev.)
+ // after the tip has been deleted *and that spend is confirmed*, when last()
+ // returns a concrete tip rev.)
expect(e.message).to.include(
'Accessing non-existent on-chain state inside a smart contract is forbidden',
)
}
await minter.delete([lastRev])
+ // last() requires a confirmed spending input of the tip — mine the delete.
+ await mine()
await t.finalWithdraw(lastRev)
expect(t.amount).to.eq(FRESH_TOKEN_AMOUNT)
@@ -952,6 +957,8 @@ describe('TBC777 - Programmable Escrow Token (No-Inflation Focus)', () => {
}
await minter.delete([lastRev])
+ // last() requires a confirmed spending input of the tip — mine the delete.
+ await mine()
await t.finalWithdraw(lastRev)
expect(t.amount).to.eq(FRESH_TOKEN_AMOUNT)
diff --git a/packages/TBC777/test/tbc777m.test.ts b/packages/TBC777/test/tbc777m.test.ts
index e71ba9c53..e87726680 100644
--- a/packages/TBC777/test/tbc777m.test.ts
+++ b/packages/TBC777/test/tbc777m.test.ts
@@ -44,6 +44,8 @@ describe('TBC777M', () => {
await Promise.all([black.faucet(10e8), white.faucet(1e8), minter.faucet(10e8)])
await ensureFunds(minter)
mod = await minter.deploy(`export ${TBC20}`)
+ // Confirm module deploy so any InnerComputer.load of `mod` is stable.
+ await mine()
})
it('Should work for a naive escrow', async () => {
diff --git a/packages/chess-app/src/components/ChessBoard.tsx b/packages/chess-app/src/components/ChessBoard.tsx
index ecb529726..762b396dd 100644
--- a/packages/chess-app/src/components/ChessBoard.tsx
+++ b/packages/chess-app/src/components/ChessBoard.tsx
@@ -211,7 +211,8 @@ function WinnerModal(data: {
Prize: {data.wagerAmount} tokens
- Click "Withdraw Tokens" on the board to collect your prize.
+ Click "Withdraw Tokens" on the board to collect your prize. Withdrawal waits
+ until the final game transaction is confirmed on-chain.
) : (
@@ -282,11 +283,10 @@ function ActionButtons({
)}
- {/* Withdraw Tokens: shown only when the chess contract declares a payout
- for my token AND my token has not already claimed against the current
- chess revision. On checkmate the winning move sets withdraws
- atomically, so the winner can withdraw immediately — no separate
- "Claim Win" round trip. */}
+ {/* Withdraw Tokens: shown when the chess contract declares a payout for my
+ token and it has not been claimed yet. The helper waits for the game
+ tip to confirm before TBC777 withdraw (InnerComputer history must be
+ confirmed). */}
{isPayoutEligible && !hasWithdrawn && (
Withdraw Tokens
@@ -610,6 +610,8 @@ export function ChessBoard() {
const myPubKey = computer.getPublicKey()
const myTokenId =
myPubKey === chessContract.publicKeyW ? chessContract.tokenIdW : chessContract.tokenIdB
+ // Helper waits for the latest chess tip to confirm before auditing deposits.
+ showSnackBar('Waiting for the game result to confirm, then withdrawing…', true)
await helper.withdrawTokens(myTokenId, chessContract._id)
await syncChessContract()
notifyGamesUpdated()
@@ -641,6 +643,8 @@ export function ChessBoard() {
try {
setIsCancelling(true)
showLoader(true)
+ // Cancel sets withdraws, then waits for confirmation, then TBC777 withdraw.
+ showSnackBar('Cancelling challenge and waiting for confirmation before refund…', true)
await helper.cancelGameAndWithdraw(chessContract._id)
if (document.getElementById(winnerModal)) {
Modal.hideModal(winnerModal)
diff --git a/packages/chess-contracts/README.md b/packages/chess-contracts/README.md
index 119cf0537..98e864269 100644
--- a/packages/chess-contracts/README.md
+++ b/packages/chess-contracts/README.md
@@ -124,6 +124,8 @@ class ChessContract extends Contract {
resign(): void
isGameOver(): boolean
hasTimedOutW / hasTimedOutB(): Promise
+ // Uses InnerComputer.txIdToBlockTime + prev on the full prev-chain.
+ // All revisions including the tip must be **confirmed** or the call is rejected.
calculateTimes(): Promise<{ timeW: bigint; timeB: bigint }>
setCanceledSeen(): void
}
@@ -133,9 +135,10 @@ class ChessContractHelper {
depositTokens(chessRev, tokenRev, wagerAmount, name, nextOwner, coSign?): Promise
move(chessId, from, to, promotion?): Promise<{ newChessContract; isGameOver }>
resign(chessId): Promise
- withdrawTokens(tokenId, chessId): Promise
+ withdrawTokens(tokenId, chessId): Promise // waits for chess tip confirmation first
cancelGame(chessId): Promise
- cancelGameAndWithdraw(chessId): Promise
+ cancelGameAndWithdraw(chessId): Promise // cancel → wait for confirm → withdraw
+ waitForConfirmed(location): Promise
markCanceledSeen(chessId): Promise
// plus query helpers (isGameStarted, canCancel, isCreator, …)
}
diff --git a/packages/chess-contracts/src/chess-contract.ts b/packages/chess-contracts/src/chess-contract.ts
index 1edf3bff1..88155bbc3 100644
--- a/packages/chess-contracts/src/chess-contract.ts
+++ b/packages/chess-contracts/src/chess-contract.ts
@@ -149,29 +149,47 @@ export class ChessContract extends Contract {
return new Chess(this.fen).isGameOver()
}
+ /**
+ * Whether white has exceeded `timeLimit` (based on confirmed block times).
+ * Requires every revision on the prev-chain (including the current tip) to be
+ * confirmed — see `calculateTimes`.
+ */
async hasTimedOutW(): Promise {
const { timeW } = await this.calculateTimes()
return timeW > this.timeLimit
}
+ /**
+ * Whether black has exceeded `timeLimit` (based on confirmed block times).
+ * Requires every revision on the prev-chain (including the current tip) to be
+ * confirmed — see `calculateTimes`.
+ */
async hasTimedOutB(): Promise {
const { timeB } = await this.calculateTimes()
return timeB > this.timeLimit
}
/**
- * Calculates white time (timeW) and black time (timeB) from a list of timestamps.
+ * Calculates white time (timeW) and black time (timeB) from block timestamps
+ * of each revision on the game’s prev-chain.
+ *
* timeW = (t2 - t1) + (t4 - t3) + (t6 - t5) + ...
* timeB = (t3 - t2) + (t5 - t4) + (t7 - t6) + ...
*
* Note: timeW + timeB will equal (tn - t1).
+ *
+ * **InnerComputer determinism:** uses `computer.txIdToBlockTime` and
+ * `computer.prev`. Both require **confirmed** transactions. Calling this (or
+ * `hasTimedOutW` / `hasTimedOutB`) while the tip is still in the mempool
+ * invalidates the transition. Callers must wait for confirmation of the
+ * latest move (and of any older history being walked) before evaluating
+ * timeouts on-chain.
*/
async calculateTimes(): Promise<{ timeW: bigint; timeB: bigint }> {
let current = this._rev
const timestamps: bigint[] = []
- // Collect every historical state. Deposits and withdrawals accumulate
- // across the entire lifetime of the escrow, so the audit must see them all.
+ // Walk tip → root. Every step must be a confirmed revision.
while (true) {
const txId = current.split(':')[0]
timestamps.push(await computer.txIdToBlockTime(txId))
@@ -349,12 +367,45 @@ export class ChessContractHelper {
return { newChessContract, isGameOver }
}
+ /**
+ * Poll until `location` (txId or rev) is included in a block.
+ * Required before TBC777 `withdraw` / InnerComputer history walks: unconfirmed
+ * tips invalidate deterministic queries (`sync` / `prev` / `next` / block time).
+ */
+ async waitForConfirmed(
+ location: string,
+ opts?: { timeoutMs?: number; pollMs?: number },
+ ): Promise {
+ const txId = location.includes(':') ? location.split(':')[0]! : location
+ const timeoutMs = opts?.timeoutMs ?? 180_000
+ const pollMs = opts?.pollMs ?? 1_500
+ const start = Date.now()
+ while (Date.now() - start < timeoutMs) {
+ try {
+ const blockHash = await this.computer.txIdToBlockHash(txId)
+ if (blockHash) return
+ } catch {
+ // not yet known / not confirmed
+ }
+ await new Promise((r) => setTimeout(r, pollMs))
+ }
+ throw new Error(
+ `Timed out waiting for confirmation of ${txId}. Try again after the transaction is mined.`,
+ )
+ }
+
+ /**
+ * Claim escrow payout for `tokenId` against the latest chess revision.
+ * Waits until that chess tip is confirmed so TBC777's InnerComputer audit
+ * (sync / prev / next on deposits) is deterministic.
+ */
async withdrawTokens(tokenId: string, chessId: string): Promise {
const latestTokenRev = await this.computer.latest(tokenId)
const latestChessRev = await this.computer.latest(chessId)
if (!this.tokenMod) {
throw new Error('tokenMod is required for TBC777 withdraw')
}
+ await this.waitForConfirmed(latestChessRev)
const { tx } = await this.computer.encode({
exp: `token.withdraw('${latestChessRev}')`,
env: { token: latestTokenRev },
@@ -448,17 +499,16 @@ export class ChessContractHelper {
}
/**
- * Cancel a pending game and withdraw the creator's wager in one flow.
- * @deprecated
- * */
+ * Cancel a pending game and withdraw the creator's wager.
+ *
+ * Cancel and withdraw cannot share one transaction: after cancel, the tip must
+ * be **confirmed** before TBC777 `withdraw` can walk escrow history. This
+ * method cancels, waits for confirmation, then withdraws.
+ */
async cancelGameAndWithdraw(chessId: string): Promise {
- await this.cancelGame(chessId)
- /**
- * Under the strict non-determinism guarantee we cannot cancel and withdraw
- * in one atomic action anymore. We now have to wait for the next block
- * before we withdraw. The app needs to be adapted accordingly.
- * */
- // await this.withdrawTokens(chess.tokenIdW, chessId)
+ const chess = await this.cancelGame(chessId)
+ await this.waitForConfirmed(chess._rev)
+ await this.withdrawTokens(chess.tokenIdW, chessId)
}
/** Mark a canceled pending game as seen by the invited opponent (clears list badge). */
diff --git a/packages/chess-contracts/test/chess-contract.test.ts b/packages/chess-contracts/test/chess-contract.test.ts
index e3230a645..bf36da5ad 100644
--- a/packages/chess-contracts/test/chess-contract.test.ts
+++ b/packages/chess-contracts/test/chess-contract.test.ts
@@ -541,6 +541,61 @@ describe('ChessContract', () => {
expect(blackTokenFinal.amount).toBe(15n)
})
+ it('calculateTimes / hasTimedOut require a confirmed tip (InnerComputer guards)', async () => {
+ const wager = 5n
+ const timeLimit = 60n * 10n
+ const { chess, chessFunded } = await fundChessGame({
+ minter,
+ white,
+ black,
+ tbc777Mod,
+ chessMod,
+ wager,
+ timeLimit,
+ })
+
+ // One unconfirmed move: tip has no block time yet → must invalidate.
+ const head = await white.latest(chess._id)
+ const toMove = await white.sync(head)
+ const { tx: moveTx, effect: moveEffect } = await white.encodeCall({
+ target: toMove,
+ property: 'move',
+ args: ['e2', 'e4', ''],
+ mod: chessMod,
+ })
+ await white.broadcast(moveTx)
+ const afterMove = (moveEffect as unknown as { env: { __bc__: unknown } }).env
+ .__bc__ as SmartContract
+ expect(afterMove._rev).not.toBe(chessFunded._rev)
+
+ await expect(async () => {
+ const { tx } = await white.encodeCall({
+ target: afterMove,
+ property: 'hasTimedOutW',
+ args: [],
+ mod: chessMod,
+ })
+ await white.broadcast(tx)
+ }).rejects.toThrow(
+ /Accessing non-existent on-chain state inside a smart contract is forbidden/,
+ )
+
+ // After the move confirms, walking prev + block times is a stable observation.
+ await minter.db.wallet.restClient.mine(1)
+ const confirmed = await white.sync(afterMove._rev)
+ const { tx: okTx, effect: okEffect } = await white.encodeCall({
+ target: confirmed,
+ property: 'hasTimedOutW',
+ args: [],
+ mod: chessMod,
+ })
+ await white.broadcast(okTx)
+ const timedOut = (okEffect as unknown as { res: boolean }).res
+ expect(typeof timedOut).toBe('boolean')
+ // Fresh game with one move should not exceed a 10-minute limit.
+ expect(timedOut).toBe(false)
+ })
+
it('Should run fool mate and credit winner balance on withdraw', async () => {
await minter.faucet(1e8)
const wager = 5n
diff --git a/packages/commodity/test/commodity.test.ts b/packages/commodity/test/commodity.test.ts
index 2a2ccc580..bf93350cd 100644
--- a/packages/commodity/test/commodity.test.ts
+++ b/packages/commodity/test/commodity.test.ts
@@ -579,6 +579,20 @@ describe('Commodity – Canonical Min-Revision Digital Commodity', function () {
// 6. claim() – Tier 1: eligibility guards
// =========================================================================
describe('claim() – Tier 1: eligibility guards (no same-block control required)', () => {
+ it('throws if claim() is called before the mint creation tx is confirmed', async () => {
+ // claim() uses InnerComputer.txIdToBlockHeight / decode / getOTXOs — all
+ // require a confirmed creation tx. Unconfirmed mints must fail closed.
+ const local = await fundedComputer()
+ const localMod = await deployCommodity(local)
+ await mineBlocks(local, 1)
+ const mint = await createMint(local, localMod, 'unconfirmed-claim-salt')
+ // Intentionally do not mine / confirmMint before claim.
+ await expectClaimFails(
+ mint,
+ /Accessing non-existent on-chain state inside a smart contract is forbidden/,
+ )
+ })
+
it('throws if called when _rev !== _root (not on the mint creation revision)', async () => {
const mint = await createMint(alice, mod)
await mint.transfer(bob.getPublicKey())
diff --git a/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md b/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
index e69de29bb..22069bdb4 100644
--- a/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
+++ b/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
@@ -0,0 +1,93 @@
+---
+title: "Sandbox & Inner Computer"
+---
+
+# Sandbox & Inner Computer
+
+Contract methods run inside a restricted SES compartment. The only chain-facing
+API available to that code is the **InnerComputer** (`computer` global): a
+read-only, fail-closed view of confirmed blockchain state.
+
+The live Retype reference lives under `packages/docs/Lib/Contract/`
+([querying](../../../docs/Lib/Contract/index.md#querying-inside-of-a-contract) and
+[sandbox](../../../docs/Lib/Contract/sandbox-and-inner-computer.md)).
+
+## Goals
+
+1. **Determinism** — If a query succeeds against a chain prefix, the same call
+ must succeed with the same result on every extension of that chain.
+2. **Fail closed** — Transient facts (mempool, “not yet”, future heights) never
+ become part of a valid transition.
+3. **No silent soft-fail** — Catching a thrown error does not clear invalidation;
+ the host still rejects if the evaluation frame is marked invalid.
+
+## Observation stability
+
+For any InnerComputer method `m` and arguments `args`: if
+`computer.m(...args)` succeeds without invalidation against chain state `b₁`,
+then on any extension `b₂ ⊇ b₁` the same call must succeed and return the same
+value.
+
+## Confirmed locations
+
+Most APIs require the referenced transaction to be **in a block** before the
+call may succeed:
+
+| Family | Rule (summary) |
+| --- | --- |
+| `sync` / `decode` / `load` / `getAncestors` | Start location/tx confirmed |
+| `first` / `prev` | Start rev confirmed; `prev` may return `undefined` at root |
+| `next` | Start and **result** confirmed; no next → invalidate |
+| `last` | Start and result confirmed; tip must be **spent confirmed** (not live unspent tip) |
+| Block time/height/hash of a tx | Tx confirmed |
+| `getBlockHash(height)` | Height ≤ tip; not future |
+| `getTXOs` (+ aliases) | Stabilizer: `lteBlockHeight` / `blockHeight` / `blockHash` |
+
+`latest` is **not** exposed inside contracts.
+
+## Invalidation flow
+
+1. Query fails or observes a transient fact (or a policy rule rejects, e.g.
+ future height).
+2. InnerComputer marks the **current evaluation frame** invalid and throws.
+ Invalidation is **frame-only** (no per-instance flags; no contract-facing
+ invalidation API). Each `Db.eval` / `Modules.load` runs under
+ `withEvalInvalidation` (dedicated eval-frame module):
+ - **Node:** `AsyncLocalStorage` (no static `node:async_hooks` import).
+ - **Browser:** await-scoped stack with serialized root frames (no Promise
+ patching under SES `lockdown`). Nested loads nest; concurrent roots queue.
+3. Host installs the active observation client on the frame; free-var query
+ methods route to that client. Invalidation always hits the **active frame**.
+4. Compartment may return after `catch` — the frame flag is **not** cleared
+ until the host checks it.
+5. Host checks **only `frame.invalid` / `frame.msg`** on both throw and
+ catch-and-continue paths.
+6. In-compartment `computer` is a **hardened query-only facade** (no
+ `isInvalid` / `resetInvalid`).
+7. Host `console` is endowed only in client `dev` / `debug` mode. In **`prod`**,
+ contracts must not use `console` (not in scope). Logging is not part of the
+ on-chain API.
+
+Error text always ends with exactly one copy of:
+
+> Accessing non-existent on-chain state inside a smart contract is forbidden.
+
+A short reason may appear before that suffix. Policy rejects and missing
+observations share the same single-suffix form (never a short reason alone,
+never a doubled forbidden sentence). Match with `message.endsWith(...)`.
+
+## Client vs contract `computer`
+
+The outer Computer client may return `undefined` for unconfirmed data and
+supports writes (`new`, `broadcast`, …). The in-contract global is a different,
+stricter surface. Full method tables: `packages/docs/Lib/Contract/`.
+
+## Practical implications
+
+- Confirm deploys before `load` in contracts.
+- Confirm object revisions before history walks or escrow audits.
+- For terminal `last` checks, spend the tip and wait for confirmation.
+- Stabilize in-contract TXO queries with a historical height or block hash;
+ empty results with a valid stabilizer are allowed.
+- Escrow/chess apps: cancel or settle, wait for confirmation, then
+ `withdraw` / refund.
diff --git a/packages/docs-2/docs/concepts/how-it-works.md b/packages/docs-2/docs/concepts/how-it-works.md
index b46a8165e..ccd4cd983 100644
--- a/packages/docs-2/docs/concepts/how-it-works.md
+++ b/packages/docs-2/docs/concepts/how-it-works.md
@@ -58,7 +58,14 @@ Inside every contract method you have access to a global `computer` object that
provides deterministic, read-only access to on-chain state. You can fetch other
smart objects with `sync(rev)`, inspect historical transactions with
`decode(txId)`, traverse ancestor chains, follow revision history with
-`first`/`prev`/`next`/`latest`, and retrieve block times with `txIdToBlockTime`.
+`first`/`prev`/`next`/`last`, load confirmed modules with `load`, query TXOs
+with stabilizing height/hash filters via `getTXOs`, and read block times with
+`txIdToBlockTime`.
+
+Successful observations must be **stable under chain extension**: unconfirmed
+(mempool) locations, “no next revision yet”, and unspent tips as `last` all
+**invalidate** the evaluation (a `try/catch` cannot clear that flag).
+`latest` is **not** available inside contracts for this reason.
Because every participant executes exactly the same sequence of deterministic
queries when replaying a transaction, the system remains safe and predictable.
@@ -170,11 +177,19 @@ blockchain operations and provides full IDE support.
[^6]:
Inside contract methods the `computer` global exposes only deterministic,
- read-only on-chain queries. Any failure during such a query (missing
- revision, RPC error, etc.) raises an internal invalidation flag
- (`globalInvalidState` in `inner-computer.ts`). After the secure compartment
- returns, `Db.eval` inspects the flag and rejects the entire transition if it
- was set. Controlled mutations required for reconstruction and metadata
+ read-only on-chain queries. Any failure or transient observation (missing
+ revision, unconfirmed location, no next successor yet, unspent tip for
+ `last`, unguarded/future `getTXOs`, RPC error, etc.) marks the current
+ **evaluation frame** invalid (`withEvalInvalidation`: ALS on Node; stack +
+ serialized roots in the browser). Invalidation is frame-only—there is no
+ per-instance flag and no contract-facing invalidation API. When the
+ compartment returns or throws, the host rejects if that frame is
+ invalid—even when the contract caught the thrown error—using only
+ `frame.invalid` / `frame.msg`. Public errors always end with a single
+ standard suffix (“Accessing non-existent on-chain state inside a smart
+ contract is forbidden.”), optionally preceded by a short reason. The
+ compartment endowment is a hardened **query-only** facade of public
+ methods. Controlled mutations required for reconstruction and metadata
attachment are performed under an explicit privilege guard (`_sudo` /
`AdminContext` in `admin.ts`) that restores the normal security invariants
afterward.
diff --git a/packages/docs-2/docs/intro.md b/packages/docs-2/docs/intro.md
index 57da15af6..279e7e3de 100644
--- a/packages/docs-2/docs/intro.md
+++ b/packages/docs-2/docs/intro.md
@@ -226,11 +226,15 @@ class Escrow extends Contract {
**Important distinction**: The `computer` available _inside_ your contract
methods is a restricted `InnerComputer`. It only exposes safe read operations
-(`sync`, `decode`, `load`, `first`/`prev`/`next`/`last`, `getAncestors`, etc.).
-It cannot create or broadcast transactions. This is a deliberate security
-boundary. The implementation also uses a global invalid-state flag as a safety
-net: if a contract attempts to access non-existent on-chain state, the entire
-evaluation is rejected.
+(`sync`, `decode`, `load`, `first`/`prev`/`next`/`last`, `getAncestors`,
+block/time helpers, guarded `getTXOs`, etc.). It cannot create or broadcast
+transactions, and it does **not** expose `latest`. Reads generally require
+**confirmed** locations; missing, mempool-only, or other transient observations
+mark the current **evaluation frame** invalid and reject the entire evaluation
+even if the contract catches the thrown error. Contracts cannot clear or hide invalidation. Rejected evaluations surface an
+error that ends with a single standard phrase: “Accessing non-existent on-chain
+state inside a smart contract is forbidden.” (A short reason may appear before
+that suffix; never the short reason alone.)
### Low-Level Control for Complex Protocols
diff --git a/packages/docs/Lib/Computer/decode.md b/packages/docs/Lib/Computer/decode.md
index 5a8d466ea..e18251288 100644
--- a/packages/docs/Lib/Computer/decode.md
+++ b/packages/docs/Lib/Computer/decode.md
@@ -34,6 +34,10 @@ An object containing the following properties:
The `decode` function takes a Bitcoin transaction or a transaction ID as input and retrieves the associated metadata if the transaction is a Bitcoin Computer transaction. This metadata includes the JavaScript expression, any environment variables, and an optional module specifier.
+### Inside smart contracts (`InnerComputer`)
+
+Only **confirmed** transactions may be decoded. Unconfirmed or missing txIds invalidate the evaluation. See [Contract – Querying](../Contract/index.md#querying-inside-of-a-contract).
+
## Example
:::code source="../../../lib/test/lib/computer/decode.test.ts" :::
diff --git a/packages/docs/Lib/Computer/first.md b/packages/docs/Lib/Computer/first.md
index 5149ca0a4..8c96ec068 100644
--- a/packages/docs/Lib/Computer/first.md
+++ b/packages/docs/Lib/Computer/first.md
@@ -22,6 +22,10 @@ Returns the first revision of the same on chain object, that is, its id.
If `first` is called with a revision for which no output exists, it throws an error `Rev not found`. If the output exists but contains no object, the same error is thrown. If the output contains an object, `first` will return the first revision of an object as indicated by the arrows in the figure below.
+### Inside smart contracts (`InnerComputer`)
+
+The starting revision must be **confirmed**. Missing or unconfirmed starts invalidate the contract evaluation. See [Contract – Querying](../Contract/index.md#querying-inside-of-a-contract).
+
[](https://wallet.bitcoincomputer.io)
## Example
diff --git a/packages/docs/Lib/Computer/getAncestors.md b/packages/docs/Lib/Computer/getAncestors.md
index 14ea1d1c5..d9f65c50c 100644
--- a/packages/docs/Lib/Computer/getAncestors.md
+++ b/packages/docs/Lib/Computer/getAncestors.md
@@ -19,6 +19,15 @@ computer.getAncestors(rev)
computer.getAncestors(rev, 1)
```
+### Inside smart contracts (`InnerComputer`)
+
+- Starting location must be **confirmed**.
+- Empty arrays are valid stable results when there are no ancestors.
+- Missing or unconfirmed starts invalidate the evaluation.
+- Verbosity maps are a client-side convenience; contracts typically use the default `string[]` form.
+
+See [Contract – Querying](../Contract/index.md#querying-inside-of-a-contract).
+
## Example
:::code source="../../../lib/test/lib/computer/get-ancestors.test.ts" :::
diff --git a/packages/docs/Lib/Computer/getTxos.md b/packages/docs/Lib/Computer/getTxos.md
index 4046f067a..ed1e22eae 100644
--- a/packages/docs/Lib/Computer/getTxos.md
+++ b/packages/docs/Lib/Computer/getTxos.md
@@ -89,6 +89,16 @@ The `getTXOs` function retrieves transaction outputs (TXOs) from the database ba
For security and efficiency, always pair this with `publicKey` to scope results to a specific owner, and apply `limit`/`offset` to manage result volume.
+### Inside smart contracts (`InnerComputer`)
+
+Off-chain clients may call `getTXOs` with any filter set. **Inside a contract**, the query must include a **stabilizing** filter so results cannot change as the chain grows:
+
+- `lteBlockHeight` (must be ≤ current tip; future heights are forbidden)
+- `blockHeight` (must be ≤ current tip)
+- `blockHash` (fixed historical block)
+
+Queries without one of these filters invalidate the evaluation. Aliases `getUTXOs`, `getOTXOs`, and `getOUTXOs` inherit the same rule. See [Contract – Querying](../Contract/index.md#querying-inside-of-a-contract).
+
To retrieve Unspent Transaction Outputs, see the syntactic sugar function `getUTXOs`, which internally calls `getTXOs` with the `isSpent: false` parameter.
To retrieve Bitcoin Computer objects, see the syntactic sugar function `getOTXOs`, which internally calls `getTXOs` with the `isObject: true` parameter.
diff --git a/packages/docs/Lib/Computer/latest.md b/packages/docs/Lib/Computer/latest.md
index bd7e11f49..da864f334 100644
--- a/packages/docs/Lib/Computer/latest.md
+++ b/packages/docs/Lib/Computer/latest.md
@@ -22,6 +22,10 @@ Returns the latest revision of the same on chain object - that is, its id.
If `latest` is called with a revision for which no output exists, it throws an error `Rev not found`. If the output exists but contains no object, the same error is thrown. If the output contains an object, `latest` will return the latest revision of an object as indicated by the arrows in the figure below.
+### Not available inside smart contracts
+
+`latest` is **not** exposed on InnerComputer (the in-contract `computer` global). The live tip can change under chain extension, so it is unsuitable for deterministic contract evaluation. Inside contracts, use confirmed history (`first` / `prev` / `getAncestors`) or terminal checks via `last` after a confirmed spend. See [Contract – Querying](../Contract/index.md#querying-inside-of-a-contract).
+
[](https://wallet.bitcoincomputer.io)
## Example
diff --git a/packages/docs/Lib/Computer/load.md b/packages/docs/Lib/Computer/load.md
index 5b51ee2e7..780ae15f5 100644
--- a/packages/docs/Lib/Computer/load.md
+++ b/packages/docs/Lib/Computer/load.md
@@ -18,6 +18,10 @@ A module specifier encoded as a string of the form `: "Accessing non-existent on-chain state inside a smart contract is forbidden."
+> If `computer.m(...args)` **succeeds without invalidation** against `b₁`, then the same call against `b₂` must succeed and return the **same value**.
-This acts as a hard safety boundary: contracts cannot silently read missing data or depend on off-chain assumptions.
+Successful observations must therefore be **invariant under future chain growth**. Transient facts (mempool-only txs, “no next revision yet”, unspent tip as “last”, future block heights) must not become part of a valid transition.
-### Full API Reference
+### How invalidation works
-All query functions available inside `Contract` methods (injected via the secure `InnerComputer` compartment):
+1. On a forbidden observation, InnerComputer marks the **current evaluation frame** invalid and throws. There is **no per-instance invalid flag**. Each `Db.eval` / `Modules.load` binds a frame via `withEvalInvalidation`:
+ - **Node:** `AsyncLocalStorage` (loaded without a static `node:async_hooks` import so browser bundles stay clean). Concurrent evals are isolated by async context.
+ - **Browser:** await-scoped stack with serialized roots (no Promise patching under SES `lockdown`). Nested loads still nest; concurrent root evals queue.
+2. The host installs the active observation client on the frame; free-var query methods route to that client for the evaluation. Invalidation always writes the **active frame**, so catch-and-continue cannot soft-succeed.
+3. A contract `try/catch` **cannot** clear invalidation — the endowment has no invalidation API. After the compartment returns **or** throws, the host rejects using **only** the frame it holds (`frame.invalid` / `frame.msg`).
+4. The compartment is endowed with a **hardened query-only facade** of InnerComputer methods (no internal `Computer` client, no `isInvalid` / `resetInvalid`, methods not replaceable).
+5. **`console` is only available in `dev` / `debug` mode.** In **`prod`**, contracts must not use `console` (it is not in scope → `ReferenceError`). Logging is not part of the deterministic on-chain API; see [Sandbox & Inner Computer](./sandbox-and-inner-computer.md#console-endowment-dev-only).
-| Function | Signature | Returns | Invalidates on missing / error? | Notes |
-| ----------------- | ------------------------------------------------------------- | ------------------------------------ | ------------------------------------ | ------------------------------------------------------------- |
-| `sync` | `sync(location: string): Promise` | The latest object state | Yes | Deep-cloned with BigInt support |
-| `decode` | `decode(txId: string): Promise` | `{ exp, env?, mod? }` metadata | Yes | Normalizes `mod` to `undefined` if absent |
-| `load` | `load(location: string): Promise>` | Module exports namespace | Yes | For dynamic module loading inside contracts |
-| `getAncestors` | `getAncestors(location: string): Promise` | Array of ancestor locations | Yes (on error) | Empty array `[]` if no ancestors |
-| `first` | `first(rev: string): Promise` | First revision in lineage | Yes | Always returns a string for valid input |
-| `prev` | `prev(rev: string): Promise` | Previous revision or `undefined` | Only on underlying error | Safe to call on tip; returns `undefined` without invalidation |
-| `next` | `next(rev: string): Promise` | Next revision or throws | **Yes, including if no next exists** | Strict: absence of next **invalidates** execution |
-| `last` | `last(rev: string): Promise` | Latest (tip) revision or `undefined` | Only on underlying error | Returns tip of lineage |
-| `txIdToBlockTime` | `txIdToBlockTime(txId: string): Promise` | Block time as `bigint` | Yes | Requires mined Bitcoin Computer transaction |
+#### Error message shape
-**Quick tip:** Use `getAncestors`, `first`, `last`, and `prev` for safe traversal. Be cautious with `next()` — it will invalidate the contract if you call it on the current tip revision.
+All invalidation errors exposed to callers end with **exactly one** copy of:
-### Detailed Function Documentation
+> Accessing non-existent on-chain state inside a smart contract is forbidden.
-#### `sync`
+- Policy rejections (for example, future `getBlockHash` height, missing `getTXOs` stabilizer) and missing/unconfirmed observations both go through a shared formatter as soon as invalidation fires. A short reason may appear **before** the standard suffix; the suffix is never doubled.
+- Uncaught throws and catch-and-continue paths share this shape: the thrown error and `frame.msg` already carry the single suffix; `Db.eval` rethrows the same canonical form when the frame is invalid.
-Returns the latest on-chain state for the given `location` (revision identifier) as a plain JavaScript object.
+Clients and tests should match with `message.endsWith(...)` (or equivalent). Do not expect a short policy reason alone without the forbidden suffix.
-```ts
-sync(location: string): Promise
-```
+### Confirmed locations only
-- Performs a deep clone (via `stringify`/`parse` with BigInt support) so contracts receive a plain, side-effect-free object.
-- If the location does not exist or cannot be synced, the evaluation is **invalidated** and an error is thrown.
+Most location-based APIs require the referenced **transaction to be confirmed** (in a block) before the call may succeed. Unconfirmed / mempool locations are treated as transient.
-#### `decode`
+| API | Confirmation rule |
+| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
+| `sync`, `decode`, `getAncestors`, `getRawTransaction` | Starting location / txId must be confirmed |
+| `load` | Module deploy location (`txId:vout`) must be confirmed |
+| `first`, `prev` | Starting revision’s tx must be confirmed |
+| `next` | Starting revision **and** returned successor must be confirmed |
+| `last` | Starting revision, returned tip, and the tip’s **spending** tx must be confirmed (unspent tip or mempool-only spend → invalidate) |
+| `txIdToBlockTime` | Tx must be confirmed (no nullish “not mined yet”) |
+| `txIdToBlockHeight` / `txIdToBlockHash` | Unconfirmed → invalidate (no block hash / not mined yet) |
+| `getTXOs` (+ `getUTXOs` / `getOTXOs` / `getOUTXOs`) | Must include a **stabilizing filter** (below); future/negative heights forbidden |
-Parses a Bitcoin transaction ID and returns its Bitcoin Computer metadata if it is a valid Bitcoin Computer transaction.
+**App / test implication:** after `deploy`, `new`, method calls, or `delete`, wait for confirmation before on-chain code that walks history, loads modules, or calls `last` / `next` / `txIdToBlockTime` on those locations.
-```ts
-decode(txId: string): Promise
-```
+### Full API reference (InnerComputer)
-Returned shape (approximate):
+| Function | Signature | Returns | Invalidates when |
+| -------------------------------- | --------------------------------- | -------------------------- | ------------------------------------------------------------ |
+| `sync` | `sync(location: string)` | Object state (deep-cloned) | Missing / unconfirmed location |
+| `decode` | `decode(txId: string)` | `{ exp, env?, mod? }` | Missing / unconfirmed tx |
+| `load` | `load(location: string)` | Module exports | Missing / unconfirmed module location |
+| `getAncestors` | `getAncestors(location: string)` | `string[]` (may be empty) | Missing / unconfirmed start; empty array is **valid** |
+| `first` | `first(rev: string)` | Creation rev (`string`) | Missing / unconfirmed start |
+| `prev` | `prev(rev: string)` | `string \| undefined` | Missing / unconfirmed start; **`undefined` at root is OK** |
+| `next` | `next(rev: string)` | next rev (`string`) | No next yet; unconfirmed start or unconfirmed successor |
+| `last` | `last(rev: string)` | Spent tip rev (`string`) | Unspent tip; mempool-only spend; unconfirmed start/result |
+| `txIdToBlockTime` | `txIdToBlockTime(txId: string)` | block time | Unconfirmed or missing tx |
+| `txIdToBlockHeight` | `txIdToBlockHeight(txId: string)` | height | Unconfirmed or missing tx |
+| `txIdToBlockHash` | `txIdToBlockHash(txId: string)` | block hash | Unconfirmed or missing tx |
+| `getBlockHash` | `getBlockHash(height: number)` | hash | Negative or **future** height; missing block |
+| `getBlockHeight` | `getBlockHeight(hash: string)` | height | Unknown hash |
+| `getRawTransaction` | `getRawTransaction(txId: string)` | hex | Unconfirmed / missing |
+| `getRawBlock` / `getBlockHeader` | by block hash | hex | Unknown hash |
+| `getTXOs` | `getTXOs(q: TXOQuery)` | revs or records | No stabilizer; future/negative height filters; query failure |
-```ts
-{
- exp: string
- env?: { [s: string]: string }
- mod?: string
-}
-```
+Aliases `getUTXOs`, `getOTXOs`, and `getOUTXOs` inherit the same rules as `getTXOs`.
-- If `mod` is falsy in the raw transition, it is normalized to `undefined`.
-- If the `txId` is malformed or does not correspond to a Bitcoin Computer transaction, the evaluation is **invalidated**.
+**`latest` is not exposed** on InnerComputer (the live tip is inherently non-deterministic under chain extension).
-#### `load`
+### Detailed notes
-Dynamically loads a module by its specifier (location) and returns its exports.
+#### `sync` / `decode` / `load`
```ts
+sync(location: string): Promise
+decode(txId: string): Promise
load(location: string): Promise>
```
-- Useful for importing shared contract logic or libraries inside a contract method.
-- If the module specifier is invalid or the module cannot be loaded, the evaluation is **invalidated**.
+- `sync` deep-clones the object (BigInt-safe) so contracts cannot mutate live graph state.
+- `decode` requires a confirmed Bitcoin Computer transaction.
+- `load` accepts a module rev (`txId:outputIndex`); the deploy transaction must be confirmed.
+- Missing or unconfirmed targets invalidate the evaluation.
#### `getAncestors`
-Returns the full ancestry chain for a revision/location as an array of revision identifiers.
-
```ts
getAncestors(location: string): Promise
```
-- Returns `[]` if there are no ancestors.
-- If the location is invalid or cannot be resolved, the evaluation is **invalidated**.
-- This is the recommended safe way to traverse history without risking invalidation from `next()`.
-
-#### `first`
+- Starting location must be confirmed.
+- An empty array is a stable result when there are no ancestors; it does **not** require special “allow null” handling (empty arrays are not nullish).
-Returns the very first (root/original) revision in the lineage of the given revision.
+#### `first` / `prev` / `next` / `last`
```ts
first(rev: string): Promise
-```
-
-- Always returns a `string` for a valid revision.
-- Invalidates the evaluation if the revision does not exist.
-
-#### `prev`
-
-Returns the immediately preceding revision in the lineage, or `undefined` if the given revision is the first in its chain.
-
-```ts
prev(rev: string): Promise
+next(rev: string): Promise
+last(rev: string): Promise
```
-- Safe to call on any revision: if there is no previous revision, it returns `undefined` **without** invalidating the contract.
-- Only invalidates on malformed input or internal retrieval errors.
+- **`first`**: starting rev must be confirmed; returns the creation revision.
+- **`prev`**: starting rev must be confirmed. `undefined` at the **confirmed root** is stable and does **not** invalidate. This is the only nullish success path among these helpers.
+- **`next`**: “no successor yet” is transient → **invalidates**. A mempool-only successor also invalidates; the returned next rev must be confirmed.
+- **`last`**: does **not** mean “current unspent tip”. An unspent tip yields `undefined` from the underlying API and **invalidates**. A definite last is the tip of a lineage whose tip is **spent in a confirmed transaction** (e.g. after a confirmed `delete`). Use this for terminal escrow checks, not for reading the live tip.
-#### `next`
-
-Returns the immediately following revision in the lineage.
+#### Block and time helpers
```ts
-next(rev: string): Promise
+txIdToBlockTime(txId: string): Promise
+txIdToBlockHeight(txId: string): Promise
+txIdToBlockHash(txId: string): Promise
+getBlockHash(height: number): Promise
+getBlockHeight(hash: string): Promise
```
-- **Critical behavior**: If there is no next revision (i.e. you are at the tip of the lineage), this function **invalidates the entire contract evaluation** and throws.
-- This is stricter than `prev()`. Use it only when you are certain a next revision must exist, or prefer `last()` / `getAncestors()` for safer traversal.
-- Invalidates on malformed input or retrieval errors as well.
-
-#### `last`
+- Unconfirmed transactions cannot be observed as stable times/heights/hashes.
+- `getBlockHash` rejects negative heights and heights **greater than the current tip** (future blocks are non-deterministic).
-Returns the most recent (tip) revision in the lineage of the given revision.
+#### `getTXOs` (and aliases)
```ts
-last(rev: string): Promise
+getTXOs(q: TXOQuery): Promise
```
-- Returns the current latest revision for that object lineage.
-- Only invalidates on errors retrieving the lineage; may return `undefined` in edge cases without invalidation.
-
-#### `txIdToBlockTime`
+Inside a contract the query **must** include one stabilizing filter:
-Returns the Unix block time (as `bigint`) at which the given transaction was included in a block.
-
-```ts
-txIdToBlockTime(txId: string): Promise
-```
+- `lteBlockHeight` — must be ≤ current tip (not in the future)
+- `blockHeight` — must be ≤ current tip
+- `blockHash` — fixed historical block
-- Useful for time-based logic inside contracts (e.g. vesting, expiration, chess time locks).
-- If the transaction is not found or has no associated block time (e.g. unmined or non-Bitcoin-Computer tx), the evaluation is **invalidated**.
+Queries without a stabilizer, or with a future/negative height, invalidate. Empty result sets with a valid stabilizer are fine (indexing lag is an application concern, not invalidation).
-### Usage Notes & Best Practices
+### Usage notes & best practices
-1. **Prefer safe traversal methods**: Use `getAncestors()`, `first()`, `last()`, and `prev()` when exploring history. Reserve `next()` for cases where you have already verified a successor must exist.
-2. **Handle `undefined`**: Several functions (`prev`, `last`, possibly `txIdToBlockTime`) can legitimately return `undefined`. Always check before using the result.
+1. **Confirm before query.** Deploy modules, create objects, update or delete tips, then wait for confirmation before contract methods that call InnerComputer on those locations.
+2. **Prefer `prev` / `getAncestors` / `first` for history walks.** Use `next` only when a confirmed successor must exist (e.g. deposit pre/post pair).
+3. **Do not treat `last` as “latest live tip”.** For terminal claims, spend the tip (e.g. `delete`) and wait for confirmation, then call `last`.
+4. **`try/catch` does not soft-fail invalidation.** Catching the throw still rejects the transition if the evaluation frame is invalid. Public errors always end with a **single** “Accessing non-existent…” suffix (policy reasons and missing locations alike).
+5. **Stabilize TXO queries** with `lteBlockHeight`, `blockHeight`, or `blockHash`.
+6. **Off-chain code** using the outer `Computer` may still see mempool data; only the in-contract `computer` global enforces these rules.
+7. **Escrow / multi-step apps:** after cancel, settle, or `delete`, wait for confirmation before a follow-up contract call that depends on `last`, deposit deltas via `next`, or confirmed history (see [Sandbox & Inner Computer – Practical implications](./sandbox-and-inner-computer.md#practical-implications)).
-This querying API, combined with the strict property update rules of `Contract`, enables powerful, verifiable, and safe on-chain logic while protecting the network from non-deterministic or invalid contract executions.
+This API, together with `Contract` property rules, enables verifiable on-chain logic while keeping evaluations fail-closed under non-deterministic observations.
diff --git a/packages/docs/Lib/Contract/sandbox-and-inner-computer.md b/packages/docs/Lib/Contract/sandbox-and-inner-computer.md
new file mode 100644
index 000000000..f2c00ba4a
--- /dev/null
+++ b/packages/docs/Lib/Contract/sandbox-and-inner-computer.md
@@ -0,0 +1,103 @@
+---
+icon: shield
+---
+
+# Sandbox & Inner Computer
+
+Contract methods run inside a restricted **SES compartment**. The only chain-facing API available to that code is the **InnerComputer** (`computer` global): a read-only, fail-closed view of **confirmed** blockchain state.
+
+For the full method reference, stabilizers, and confirmation rules, see [Querying inside of a Contract](./index.md#querying-inside-of-a-contract).
+
+## Goals
+
+1. **Determinism** — If a query succeeds against a chain prefix, the same call must succeed with the same result on every extension of that chain.
+2. **Fail closed** — Transient facts (mempool, “not yet”, future heights) never become part of a valid transition.
+3. **No silent soft-fail** — Catching a thrown error does not clear invalidation; after the compartment returns, the host still rejects the transition if the evaluation was marked invalid.
+
+## Observation stability
+
+For any InnerComputer method `m` and arguments `args`: if `computer.m(...args)` succeeds without invalidation against chain state `b₁`, then on any extension `b₂ ⊇ b₁` the same call must succeed and return the same value.
+
+Equivalently: every successful observation is **invariant under future chain growth**.
+
+## Confirmed locations (summary)
+
+Most APIs require the referenced transaction to be **in a block** before the call may succeed:
+
+| Family | Rule (summary) |
+| ------------------------------------------- | ------------------------------------------------------------------------------------ |
+| `sync` / `decode` / `load` / `getAncestors` | Start location/tx confirmed |
+| `first` / `prev` | Start rev confirmed; `prev` may return `undefined` at root |
+| `next` | Start **and** returned successor confirmed; no next → invalidate |
+| `last` | Start and result confirmed; tip must be **spent confirmed** (not a live unspent tip) |
+| Block time/height/hash of a tx | Tx confirmed |
+| `getBlockHash(height)` | Height ≤ tip; not future |
+| `getTXOs` (+ aliases) | Stabilizer: `lteBlockHeight` / `blockHeight` / `blockHash` |
+
+`latest` is **not** exposed inside contracts (the live tip is non-deterministic under chain extension).
+
+## Invalidation flow
+
+1. A query fails or observes a transient fact (or a direct policy rule rejects, e.g. future height).
+2. InnerComputer marks the **current evaluation frame** invalid and throws. There is **no per-instance invalid flag** and no contract-facing invalidation API. Each `Db.eval` / `Modules.load` runs under `withEvalInvalidation` (implementation lives in a dedicated eval-frame module):
+ - **Node:** `AsyncLocalStorage` via `process.getBuiltinModule('async_hooks')` (no static `node:async_hooks` import, so browser bundles stay clean). Concurrent evaluations are truly concurrent and isolated by async context.
+ - **Browser:** await-scoped stack with **serialized root** frames (no Promise patching under SES `lockdown`). Nested frames (e.g. `Modules.load` inside `Db.eval`) still nest; concurrent root evals queue so stack tops never cross-talk.
+3. The host sets the active observation client on the frame. Free-variable `computer` methods **route to that client** for the evaluation, so create-time SES bindings and the eval-time client share one observation identity. Invalidation always writes the **active frame**.
+4. The compartment may return after `catch` — the frame flag is **not** cleared until the host has checked it.
+5. The host accepts or rejects using **only `frame.invalid` / `frame.msg`**. If the compartment throws *or* returns after catch-and-continue, `Db.eval` still rejects when the frame is marked invalid.
+6. The in-compartment `computer` is a **hardened query-only facade**: public observation methods only (no `isInvalid` / `errorMsg` / `resetInvalid`, no internal client object, methods not replaceable).
+
+### `console` endowment (dev only)
+
+Compartment globals include host `console` only when the client `mode` is **`dev`** or **`debug`**.
+
+In **`prod`**, `console` is **not** endowed. Contract or module code that references `console` gets a `ReferenceError` and the evaluation fails. **Do not use `console` in production smart contracts** — logging is not part of the on-chain API, and endowing the shared host `console` is ambient authority (especially without SES `lockdown`, which runs in prod).
+
+Use off-chain tooling and the outer `Computer` client for diagnostics.
+
+### Error message shape
+
+Every invalidation path (policy `_invalidate`, missing/nullish `_safeCall`, and host rethrow from `Db.eval`) builds the public string with a shared formatter so callers always see **exactly one** copy of:
+
+> Accessing non-existent on-chain state inside a smart contract is forbidden.
+
+A short reason may appear before that suffix, for example:
+
+- `getBlockHash with future height is forbidden. Accessing non-existent on-chain state inside a smart contract is forbidden.`
+- `Transaction id … not found or not yet confirmed by sync. Accessing non-existent on-chain state inside a smart contract is forbidden.`
+
+The formatter is idempotent (already-suffixed strings are not doubled). Match with `message.endsWith(...)` (or equivalent). Do **not** expect a short policy reason alone without the standard suffix.
+
+## Client vs contract `computer`
+
+| | Outer [`Computer`](../Computer/index.md) | InnerComputer (`computer` in contracts) |
+| ---------------------------------------- | ---------------------------------------- | --------------------------------------- |
+| Writes (`new`, `broadcast`, …) | Yes | No |
+| Mempool / unconfirmed reads | Often allowed (may return `undefined`) | Forbidden → invalidate |
+| `latest` | Yes | **Not exposed** |
+| `getTXOs` without height/hash stabilizer | Yes | Forbidden → invalidate |
+| Host `console` in compartment | N/A | **`dev` / `debug` only** (not in `prod`) |
+| Concurrent evaluations | Multiple clients/calls | Isolated eval frames (ALS on Node; serialized roots in browser) |
+
+## Security notes (what contracts cannot do)
+
+- Contracts cannot create or clear eval frames (`withEvalInvalidation` is host-only).
+- The endowment exposes **query methods only** — there is no `isInvalid`, `errorMsg`, `resetInvalid`, or `resetGlobalInvalid` for contracts to call or shadow.
+- The endowment is hardened so contracts cannot replace `sync` / `first` / … or reassign the prototype to hide invalidation.
+- Host reject decisions use the **frame only**.
+- In **`prod`**, contracts cannot use `console` (not endowed). Prefer no logging in on-chain code at all.
+
+## Practical implications
+
+- Confirm module deploys before contract `load`.
+- Confirm object revisions before history walks or escrow audits.
+- For terminal `last` checks, spend the tip (e.g. `delete`) and wait for confirmation.
+- Stabilize in-contract TXO queries with a historical height or block hash; empty result sets with a valid stabilizer are fine (wait for indexing if apps/tests expect a known object to appear).
+- Escrow / chess flows: cancel or settle, **wait for confirmation**, then `withdraw` / refund (cancel and withdraw cannot be one atomic observation of unconfirmed tip spend).
+- Do not ship contract methods that call `console.*` if they must run under `mode: 'prod'`.
+
+## See also
+
+- [Contract – Querying API](./index.md#querying-inside-of-a-contract)
+- [Computer API](../Computer/index.md)
+- [How it Works](../../how-it-works.md)
diff --git a/packages/docs/Lib/index.md b/packages/docs/Lib/index.md
index 5fbda7ffa..8ae8c943e 100644
--- a/packages/docs/Lib/index.md
+++ b/packages/docs/Lib/index.md
@@ -13,5 +13,7 @@ The Bitcoin Computer Library has the exports below.
|-----------------------|-----------------------------------------|
| [Computer](./Computer/) | Read and write smart contracts |
| [Transaction](./Transaction/) | Parse Bitcoin Computer transactions |
-| [Contract](./Contract//) | Extend from this class to create smart contracts |
+| [Contract](./Contract/) | Extend from this class to create smart contracts |
+| [Contract – Querying / InnerComputer](./Contract/index.md#querying-inside-of-a-contract) | Deterministic on-chain reads inside methods |
+| [Sandbox & Inner Computer](./Contract/sandbox-and-inner-computer.md) | SES sandbox, eval-frame invalidation, hardened endowment |
| [Mock](./Mock/) | Mock on-chain objects |
diff --git a/packages/docs/how-it-works.md b/packages/docs/how-it-works.md
index 214c3c480..c2faff9df 100644
--- a/packages/docs/how-it-works.md
+++ b/packages/docs/how-it-works.md
@@ -44,6 +44,14 @@ We call a string of the form `id:num` where `id` is a transaction id and `num` i
- `sync` maps a revision to a value.
- `encode` maps an expression and a blockchain environment to a transaction.
+## The Inner Computer
+
+Outside the protocol, the library client can create, fund, and broadcast transactions and may read mempool state. **Inside** a smart-contract method, evaluation runs in a restricted sandbox. The only chain-facing API is a global `computer` object (**InnerComputer**): contracts can load other objects, walk revision history, and read confirmed block times, but they cannot write or broadcast.
+
+Those reads must be deterministic. Successful observations have to stay valid as the chain grows; unstable results invalidate the whole evaluation so every honest validator replaying the method gets the same outcome. A `try/catch` inside the contract cannot soft-succeed. Host `console` is available inside contracts only in `dev` / `debug` client mode — **not in `prod`**. This enables patterns such as escrows whose conditions depend on other contracts or on confirmed time.
+
+The full API, confirmation rules, error shape, and sandbox model are documented under [Contract – Querying](./Lib/Contract/index.md#querying-inside-of-a-contract) and [Sandbox & Inner Computer](./Lib/Contract/sandbox-and-inner-computer.md).
+
## Provenance
If the value returned from _sync_ contains an object, it has extra properties _\_id_, _\_rev_, _\_root_ that specify its location on the blockchain and its provenance.
diff --git a/packages/docs/language.md b/packages/docs/language.md
index c63c6e8be..5ba975c26 100644
--- a/packages/docs/language.md
+++ b/packages/docs/language.md
@@ -11,6 +11,8 @@ In order to make it possible to write smart contracts in JavaScript, the Bitcoin
We call an expression a _smart contract_ if it returns a value that contains only sub-objects whose classes that extend from `Contract`. The function `encode` throws an error if it called with an expression that is not a smart contract. Likewise, the function `sync` throws an error if it is called with a transaction whose expression is not a smart contract.
+Inside contract methods, a restricted global `computer` (**InnerComputer**) provides deterministic, confirmed-only chain reads. Transient observations invalidate the evaluation (a `try/catch` cannot soft-succeed); public errors end with a single standard forbidden suffix. Host `console` is available only in `dev` / `debug` client mode — **not in `prod`** (do not rely on `console` in production contracts). See [Contract – Querying](./Lib/Contract/index.md#querying-inside-of-a-contract) and [Sandbox & Inner Computer](./Lib/Contract/sandbox-and-inner-computer.md).
+
To describe the behavior of `Contract` more precisely, let `obj` be an object of a class that extends from `Contract`. Then an error is thrown if either
1. a property of `obj` is assigned outside of a method of `obj`,