Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions packages/TBC777/src/tbc777.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 6 additions & 3 deletions packages/TBC777/src/tbc777m.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<bigint> {
if ((await computer.last(rev)) !== rev) return 0n
Expand Down
9 changes: 8 additions & 1 deletion packages/TBC777/test/tbc777.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions packages/TBC777/test/tbc777m.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
16 changes: 10 additions & 6 deletions packages/chess-app/src/components/ChessBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,8 @@ function WinnerModal(data: {
Prize: {data.wagerAmount} tokens
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Click &quot;Withdraw Tokens&quot; on the board to collect your prize.
Click &quot;Withdraw Tokens&quot; on the board to collect your prize. Withdrawal waits
until the final game transaction is confirmed on-chain.
</p>
</div>
) : (
Expand Down Expand Up @@ -282,11 +283,10 @@ function ActionButtons({
</button>
)}

{/* 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 && (
<button onClick={onWithdraw} className={buttonStyles}>
Withdraw Tokens
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions packages/chess-contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ class ChessContract extends Contract {
resign(): void
isGameOver(): boolean
hasTimedOutW / hasTimedOutB(): Promise<boolean>
// 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
}
Expand All @@ -133,9 +135,10 @@ class ChessContractHelper {
depositTokens(chessRev, tokenRev, wagerAmount, name, nextOwner, coSign?): Promise<SmartContract>
move(chessId, from, to, promotion?): Promise<{ newChessContract; isGameOver }>
resign(chessId): Promise<SmartContract>
withdrawTokens(tokenId, chessId): Promise<void>
withdrawTokens(tokenId, chessId): Promise<void> // waits for chess tip confirmation first
cancelGame(chessId): Promise<SmartContract>
cancelGameAndWithdraw(chessId): Promise<void>
cancelGameAndWithdraw(chessId): Promise<void> // cancel → wait for confirm → withdraw
waitForConfirmed(location): Promise<void>
markCanceledSeen(chessId): Promise<SmartContract>
// plus query helpers (isGameStarted, canCancel, isCreator, …)
}
Expand Down
76 changes: 63 additions & 13 deletions packages/chess-contracts/src/chess-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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<boolean> {
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))
Expand Down Expand Up @@ -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<void> {
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<void> {
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 },
Expand Down Expand Up @@ -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<void> {
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). */
Expand Down
55 changes: 55 additions & 0 deletions packages/chess-contracts/test/chess-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ChessContract>(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<typeof ChessContract>
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<typeof ChessContract>(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
Expand Down
14 changes: 14 additions & 0 deletions packages/commodity/test/commodity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading