Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/middleware/auth-express-middleware/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"typescript": "^5.2.2"
},
"dependencies": {
"@bsv/sdk": "^2.1.0",
"@bsv/sdk": "workspace:*",
"express": "^5.1.0"
}
}
4 changes: 2 additions & 2 deletions packages/middleware/payment-express-middleware/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"author": "BSV Blockchain Association",
"license": "SEE LICENSE IN LICENSE.txt",
"devDependencies": {
"@bsv/auth-express-middleware": "^2.0.5",
"@bsv/auth-express-middleware": "workspace:*",
"@types/body-parser": "^1.19.5",
"@types/jest": "^30.0.0",
"jest": "^30.3.0",
Expand All @@ -60,7 +60,7 @@
"typescript": "^5.2.2"
},
"dependencies": {
"@bsv/sdk": "^2.1.0",
"@bsv/sdk": "workspace:*",
"express": "^5.1.0"
},
"bugs": {
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ All notable changes to this project will be documented in this file. The format

### Changed

- `AbortActionResult.aborted` is now typed `boolean` (was the literal `true`). Wallets implementing the BRC-100 interface may now return `aborted: false` when they refuse to abort an action because the underlying transaction is positively confirmed on chain. The recommended caller follow-up is `internalizeAction`. Network-unreachable conditions still return `aborted: true` — refusal is reserved for positive on-chain confirmation. Source: `packages/sdk/src/wallet/Wallet.interfaces.ts`.

### Deprecated

### Removed
Expand Down
17 changes: 16 additions & 1 deletion packages/sdk/src/wallet/Wallet.interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,8 +412,23 @@ export interface AbortActionArgs {
reference: Base64String
}

/**
* Result of an `abortAction` call.
*
* `aborted` is informative: `true` indicates the wallet successfully invalidated
* the action (it will not be broadcast and its inputs are released), `false`
* indicates the wallet refused to abort because the underlying transaction was
* found to already be on chain (mined or known to mempool). On a refusal the
* caller should typically invoke `internalizeAction` instead, which will treat
* the call as explicit authorization to advance the nosend lifecycle.
*
* Note that confirming on-chain status requires network reachability. When
* confirmation is impossible (services unreachable or returning errors), the
* wallet proceeds with the abort and returns `aborted: true` rather than
* refusing — refusal is reserved for positive on-chain confirmation.
*/
export interface AbortActionResult {
aborted: true
aborted: boolean
}

export type AcquireCertificateResult = WalletCertificate
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/src/wallet/WalletClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export default class WalletClient implements WalletInterface {

async abortAction (args: {
reference: Base64String
}): Promise<{ aborted: true }> {
}): Promise<{ aborted: boolean }> {
validateAbortActionArgs(args)
await this.connectToSubstrate()
return await (this.substrate as WalletInterface).abortAction(
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/src/wallet/substrates/window.CWI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export default class WindowCWISubstrate implements WalletInterface {
async abortAction(
args: { reference: Base64String },
originator?: OriginatorDomainNameStringUnder250Bytes
): Promise<{ aborted: true }> {
): Promise<{ aborted: boolean }> {
return await this.CWI.abortAction(args, originator)
}

Expand Down
6 changes: 6 additions & 0 deletions packages/wallet/wallet-toolbox/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ attention to changes that materially alter behavior or extend functionality.

## wallet-toolbox (unreleased)

- **Public API change**: `AbortActionResult.aborted` is now typed `boolean` (was the literal `true`). The wallet returns `aborted: false` when it positively confirms the underlying transaction is already on chain (mined or known to mempool) and the abort therefore should not proceed. Callers branching on `result.aborted` should treat `false` as "refused due to on-chain confirmation" and typically follow up with `internalizeAction`. Service-unreachable conditions return `aborted: true` (with an `abortAction-offline-fallback` history note) — refusal is reserved for positive on-chain confirmation, per BRC-100.

- Fix: close the nosend orphan-output failure mode. A `nosend` transaction (created via `createAction({noSend:true})`) could be externally broadcast and confirmed on chain before any `internalizeAction` or `Monitor.TaskCheckNoSends` cycle retired its `nosend` status. Before this change, two paths could then destroy the wallet's bookkeeping for the chain-confirmed tx: `StorageProvider.abortAction` unconditionally promoted `transactions.status` to `'failed'` and `proven_tx_reqs.status` to terminal `'invalid'`, hiding every output the tx produced (including the wallet's own auto-fund change) from the `listOutputsKnex` `txStatusAllowed` filter; and `specOpNoSendActions.postProcess` (bulk-abort path) blanket-set `tx.status = 'failed'` regardless of per-row outcome. Additionally `mergedInternalize` never advanced `transactions.status` or `proven_tx_reqs.status` out of `nosend`, so even a correctly-issued post-broadcast `internalizeAction` silently no-op'd on the lifecycle. Four fixes in defense-in-depth: (1) `mergedInternalize` retires the nosend lifecycle (transition to `unproven` + req `unmined`, or all the way to `completed` if the BEEF carries a BUMP); (2) `Monitor.processNewBlockHeader` now nudges `TaskCheckNoSends.checkNow` alongside the existing `TaskCheckForProofs.checkNow` nudge, wiring up the documented-but-orphaned per-block trigger; (3) `StorageProvider.abortAction` chain-checks signed `nosend` txs via `services.getStatusForTxids` and returns `aborted: false` for `mined` or `known` (mempool-aware) txs without throwing; (4) `specOpNoSendActions.postProcess` pre-filters chain-known rows before bulk-abort and honors per-row `aborted: false` returns so race-window rows that became chain-known mid-page leave their status as `nosend` rather than being blanket-set to `failed`. Service-unreachable handling proceeds with the abort and writes an `abortAction-offline-fallback` history note for forensic audit — abort must remain possible when network confirmation is impossible. One residual edge case (backend returns success+`unknown` for a tx that is actually on chain) is documented in the PR; mitigations include the per-block `TaskCheckNoSends` nudge eventually self-healing and caller-side multi-source chain verification.

- Optimization: `TaskCheckNoSends` aging schedule on the block-triggered `checkNow` path. The unfiltered per-block scan would do an unbounded number of external `getMerklePath` lookups per block as a wallet's `nosend` set grows (escrow, un-aborted tests, abandoned batches). The new schedule keys on row age: rows fresher than 5 min are skipped entirely (protecting in-flight batched-tx workflows that chain `createAction({noSend:true, sendWith:[...]})` builds); 5 min – 1 hr rows check on every trigger; older rows progress to `~hourly` (block-height % 6), `~daily` (% 144), and `~weekly` (% 1008) cadences. Each row's modulo offset is keyed by its `provenTxReqId` so same-tier rows are staggered across the cycle (`(blockHeight + provenTxReqId) % tierInterval === 0`) rather than all firing on the same block. The scheduled daily (no-`checkNow`) cadence is unchanged and still scans every row, providing a safety net for externally-broadcast unmined `nosend` txs regardless of age.

## wallet-toolbox 2.1.27

- Add optional `contactSource` (and exported `ContactSource` / `ContactRecord` interfaces) on `WalletArgs` and the `Wallet` class. When provided, `Wallet.discoverByIdentityKey` consults the local contacts source **before** the in-process `_overlayCache` and before any network call; on a hit, the overlay is not queried at all. `Wallet.discoverByAttributes` consults the contact source's optional `findByAttributes` when present. Contact-source failures are swallowed and fall through to the existing network path so the network is never gated on a flaky contact store.
Expand Down
2 changes: 1 addition & 1 deletion packages/wallet/wallet-toolbox/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
},
"homepage": "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/client#readme",
"dependencies": {
"@bsv/sdk": "^2.1.0",
"@bsv/sdk": "workspace:*",
"hash-wasm": "^4.12.0",
"idb": "^8.0.2"
}
Expand Down
2 changes: 1 addition & 1 deletion packages/wallet/wallet-toolbox/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
},
"homepage": "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox/mobile#readme",
"dependencies": {
"@bsv/sdk": "^2.1.0",
"@bsv/sdk": "workspace:*",
"hash-wasm": "^4.12.0",
"idb": "^8.0.2"
}
Expand Down
6 changes: 3 additions & 3 deletions packages/wallet/wallet-toolbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@
},
"homepage": "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wallet-toolbox#readme",
"dependencies": {
"@bsv/auth-express-middleware": "^2.0.5",
"@bsv/payment-express-middleware": "^2.0.2",
"@bsv/sdk": "workspace:^2.1.1",
"@bsv/auth-express-middleware": "workspace:*",
"@bsv/payment-express-middleware": "workspace:*",
"@bsv/sdk": "workspace:*",
"better-sqlite3": "^12.6.2",
"express": "^4.21.2",
"hash-wasm": "^4.12.0",
Expand Down
12 changes: 12 additions & 0 deletions packages/wallet/wallet-toolbox/src/monitor/Monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,18 @@ export class Monitor {
// console.log(`WalletMonitor notified of new block header ${h.height}`)
// Nudge the proof checker to try again.
TaskCheckForProofs.checkNow = true
// Nudge the nosend checker too. Externally-broadcast nosend txs
// (created via createAction({noSend:true}) and broadcast by the
// caller through ARC, a ladder, or other external paths) may have
// confirmed in this new block. TaskCheckNoSends.runTask processes
// 'nosend' reqs via getProofs the same way TaskCheckForProofs
// processes 'unmined'/'unknown'/... reqs; without this nudge the
// task only fires on its triggerMsecs cadence (default once per
// day) which combined with intermittent wallet uptime means the
// nosend lifecycle has no reliable retirement path. The
// TaskCheckNoSends.checkNow flag was designed for this signal
// (see TaskCheckNoSends.ts:22-25) but was never wired.
TaskCheckNoSends.checkNow = true
}

/**
Expand Down
112 changes: 108 additions & 4 deletions packages/wallet/wallet-toolbox/src/monitor/tasks/TaskCheckNoSends.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getProofs } from './TaskCheckForProofs'
import { WalletMonitorTask } from './WalletMonitorTask'

/**
* `TaskCheckNoSends` is a WalletMonitor task that retreives merkle proofs for
* `TaskCheckNoSends` is a WalletMonitor task that retrieves merkle proofs for
* 'nosend' transactions that MAY have been shared externally.
*
* Unlike intentionally processed transactions, 'nosend' transactions are fully valid
Expand All @@ -14,6 +14,42 @@ import { WalletMonitorTask } from './WalletMonitorTask'
*
* If a proof is obtained and validated, a new ProvenTx record is created and
* the original ProvenTxReq status is advanced to 'notifying'.
*
* # Aging schedule on the checkNow path
*
* When this task is triggered by a new block header (`checkNow = true`, wired in
* `Monitor.processNewBlockHeader`), it does NOT scan every `nosend` row on every
* block. The set of `nosend` rows can grow large over a wallet's lifetime
* (txs sitting in escrow, un-aborted tests, abandoned batches), and a fast,
* unfiltered scan on every block would do an unbounded number of external
* `getMerklePath` lookups per block.
*
* Instead, the row's age (now - `created_at`) determines how often it is
* eligible for a checkNow-triggered chain check. The schedule starts at "skip
* entirely" for very fresh rows (to protect in-flight batched-tx workflows
* where chained `createAction({ noSend: true, sendWith: [...] })` builds
* deliberately keep rows in `nosend` until a single terminator broadcasts the
* whole BEEF), then progresses to "every block", "hourly", "daily", and
* "weekly" as rows age:
*
* age < 5 min → skip (in-flight batch protection)
* 5 min ≤ age < 1 hr → check on every checkNow trigger
* 1 hr ≤ age < 24 hr → check on ~hourly cadence (block-height % 6)
* 24 hr ≤ age < 7 days → check on ~daily cadence (block-height % 144)
* age ≥ 7 days → check on ~weekly cadence (block-height % 1008)
*
* Block-height modulo gives a deterministic, stateless way to schedule
* checks for older rows; no per-row "last checked" persistence is required.
* Each row's modulo offset is keyed by its `provenTxReqId` so that rows in
* the same age tier are staggered across the modulo cycle rather than all
* firing on the same block — `(blockHeight + provenTxReqId) % tierInterval`.
* For a wallet with N rows in tier T and tier interval K, this gives
* roughly N/K rows fired per block instead of N rows fired every K blocks.
*
* The scheduled daily cadence (no `checkNow`) is unaffected — it still scans
* every row regardless of age. That path is the once-per-day fallback that
* guarantees externally-broadcast `nosend` txs are eventually recognized
* even if the aging schedule on the checkNow path defers them.
*/
export class TaskCheckNoSends extends WalletMonitorTask {
static readonly taskName = 'CheckNoSends'
Expand All @@ -24,6 +60,48 @@ export class TaskCheckNoSends extends WalletMonitorTask {
*/
static checkNow = false

/**
* Aging-schedule constants for the `checkNow` path. Rows below `tier0FreshSkipMsecs`
* are never checked via checkNow (batched-tx protection). Rows from tier 0 up
* to `tier1EveryBlockMsecs` are checked on every checkNow trigger. Beyond that,
* checks happen on `block-height % tierNBlockInterval === 0` cadences with
* growing intervals. The scheduled daily cadence (no checkNow) is unaffected.
*/
static readonly tier0FreshSkipMsecs = 5 * 60 * 1000 // 5 min
static readonly tier1EveryBlockMsecs = 60 * 60 * 1000 // 1 hr
static readonly tier2HourlyMsecs = 24 * 60 * 60 * 1000 // 24 hr
static readonly tier3DailyMsecs = 7 * 24 * 60 * 60 * 1000 // 7 days
static readonly tier2BlockInterval = 6 // ~hourly on 10-min blocks
static readonly tier3BlockInterval = 144 // ~daily on 10-min blocks
static readonly tier4BlockInterval = 1008 // ~weekly on 10-min blocks

/**
* Decide whether a single `nosend` row should be chain-checked on the
* current `checkNow` trigger, based on its age, the current block
* height, and its `provenTxReqId` (used to stagger same-tier rows
* across the modulo cycle). See class docstring for the full schedule
* and staggering rationale.
*/
static shouldCheckOnCheckNow (
createdAt: Date,
nowMs: number,
currentBlockHeight: number,
provenTxReqId: number
): boolean {
const ageMs = nowMs - createdAt.getTime()
if (ageMs < TaskCheckNoSends.tier0FreshSkipMsecs) return false
if (ageMs < TaskCheckNoSends.tier1EveryBlockMsecs) return true
// Stagger same-tier rows by `provenTxReqId` so they fire on different
// blocks within the modulo cycle, not all on the same block.
if (ageMs < TaskCheckNoSends.tier2HourlyMsecs) {
return (currentBlockHeight + provenTxReqId) % TaskCheckNoSends.tier2BlockInterval === 0
}
if (ageMs < TaskCheckNoSends.tier3DailyMsecs) {
return (currentBlockHeight + provenTxReqId) % TaskCheckNoSends.tier3BlockInterval === 0
}
return (currentBlockHeight + provenTxReqId) % TaskCheckNoSends.tier4BlockInterval === 0
}

constructor (
monitor: Monitor,
public triggerMsecs = Monitor.oneDay * 1
Expand All @@ -44,14 +122,17 @@ export class TaskCheckNoSends extends WalletMonitorTask {

async runTask (): Promise<string> {
let log = ''
const countsAsAttempt = TaskCheckNoSends.checkNow
const wasCheckNow = TaskCheckNoSends.checkNow
const countsAsAttempt = wasCheckNow
TaskCheckNoSends.checkNow = false

const maxAcceptableHeight = this.monitor.lastNewHeader?.height
if (maxAcceptableHeight === undefined) {
return log
}

const nowMs = Date.now()

const limit = 100
let offset = 0
for (;;) {
Expand All @@ -62,8 +143,31 @@ export class TaskCheckNoSends extends WalletMonitorTask {
})
if (reqs.length === 0) break
log += `${reqs.length} reqs with status 'nosend'\n`
const r = await getProofs(this, reqs, maxAcceptableHeight, 2, countsAsAttempt, false)
log += `${r.log}\n`

// On the checkNow (block-triggered) path, apply the aging schedule
// — see class docstring. The scheduled daily cadence is unfiltered
// so externally-broadcast unmined nosend txs are eventually caught
// regardless of age.
let eligible = reqs
if (wasCheckNow) {
eligible = reqs.filter(r =>
TaskCheckNoSends.shouldCheckOnCheckNow(
r.created_at,
nowMs,
maxAcceptableHeight,
r.provenTxReqId
)
)
const skipped = reqs.length - eligible.length
if (skipped > 0) {
log += `aging schedule: skipping ${skipped} of ${reqs.length} reqs on checkNow path (block-triggered)\n`
}
}

if (eligible.length > 0) {
const r = await getProofs(this, eligible, maxAcceptableHeight, 2, countsAsAttempt, false)
log += `${r.log}\n`
}
if (reqs.length < limit) break
offset += limit
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ import { WERR_INTERNAL, WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors'
* Processing starts with simple validation and then checks for a pre-existing transaction.
* If the transaction is already known to the user, then the outputs are reviewed against the existing outputs treatment,
* and merge rules are added to the arguments passed to the storage layer.
* The existing transaction must be in the 'unproven' or 'completed' status. Any other status is an error.
*
* The existing transaction's `status` determines what the merge path does next:
* - `'unproven'` or `'completed'`: outputs are merged; status is left as-is.
* - `'nosend'`: the `internalizeAction` call is treated as explicit authorization to advance the lifecycle.
* `transactions.status` is promoted to `'completed'` (BUMP-bearing BEEF) or `'unproven'` (otherwise), and the
* `proven_tx_req` is moved out of `'nosend'` so Monitor's proof-fetching flow can finalize it. See storage-layer
* `internalizeAction` docs for the full rationale.
* - Any other status: an error.
*
* When the transaction already exists, the description is updated. The isOutgoing sense is not changed.
*
Expand Down
Loading
Loading