@bsv/sdk 1.7.7 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/src/kvstore/GlobalKVStore.js +420 -0
- package/dist/cjs/src/kvstore/GlobalKVStore.js.map +1 -0
- package/dist/cjs/src/kvstore/LocalKVStore.js +6 -6
- package/dist/cjs/src/kvstore/LocalKVStore.js.map +1 -1
- package/dist/cjs/src/kvstore/kvStoreInterpreter.js +74 -0
- package/dist/cjs/src/kvstore/kvStoreInterpreter.js.map +1 -0
- package/dist/cjs/src/kvstore/types.js +11 -0
- package/dist/cjs/src/kvstore/types.js.map +1 -0
- package/dist/cjs/src/overlay-tools/Historian.js +153 -0
- package/dist/cjs/src/overlay-tools/Historian.js.map +1 -0
- package/dist/cjs/src/script/templates/PushDrop.js +2 -2
- package/dist/cjs/src/script/templates/PushDrop.js.map +1 -1
- package/dist/cjs/src/transaction/Transaction.js +4 -4
- package/dist/cjs/src/transaction/Transaction.js.map +1 -1
- package/dist/cjs/src/transaction/fee-models/LivePolicy.js +90 -0
- package/dist/cjs/src/transaction/fee-models/LivePolicy.js.map +1 -0
- package/dist/cjs/src/transaction/fee-models/index.js +3 -1
- package/dist/cjs/src/transaction/fee-models/index.js.map +1 -1
- package/dist/cjs/src/wallet/WalletClient.js +43 -52
- package/dist/cjs/src/wallet/WalletClient.js.map +1 -1
- package/dist/cjs/src/wallet/substrates/WalletWireProcessor.js +19 -0
- package/dist/cjs/src/wallet/substrates/WalletWireProcessor.js.map +1 -1
- package/dist/cjs/src/wallet/substrates/WalletWireTransceiver.js +18 -1
- package/dist/cjs/src/wallet/substrates/WalletWireTransceiver.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/kvstore/GlobalKVStore.js +416 -0
- package/dist/esm/src/kvstore/GlobalKVStore.js.map +1 -0
- package/dist/esm/src/kvstore/LocalKVStore.js +6 -6
- package/dist/esm/src/kvstore/LocalKVStore.js.map +1 -1
- package/dist/esm/src/kvstore/kvStoreInterpreter.js +47 -0
- package/dist/esm/src/kvstore/kvStoreInterpreter.js.map +1 -0
- package/dist/esm/src/kvstore/types.js +8 -0
- package/dist/esm/src/kvstore/types.js.map +1 -0
- package/dist/esm/src/overlay-tools/Historian.js +155 -0
- package/dist/esm/src/overlay-tools/Historian.js.map +1 -0
- package/dist/esm/src/script/templates/PushDrop.js +2 -2
- package/dist/esm/src/script/templates/PushDrop.js.map +1 -1
- package/dist/esm/src/transaction/Transaction.js +4 -4
- package/dist/esm/src/transaction/Transaction.js.map +1 -1
- package/dist/esm/src/transaction/fee-models/LivePolicy.js +85 -0
- package/dist/esm/src/transaction/fee-models/LivePolicy.js.map +1 -0
- package/dist/esm/src/transaction/fee-models/index.js +1 -0
- package/dist/esm/src/transaction/fee-models/index.js.map +1 -1
- package/dist/esm/src/wallet/WalletClient.js +43 -52
- package/dist/esm/src/wallet/WalletClient.js.map +1 -1
- package/dist/esm/src/wallet/substrates/WalletWireProcessor.js +19 -0
- package/dist/esm/src/wallet/substrates/WalletWireProcessor.js.map +1 -1
- package/dist/esm/src/wallet/substrates/WalletWireTransceiver.js +18 -1
- package/dist/esm/src/wallet/substrates/WalletWireTransceiver.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/kvstore/GlobalKVStore.d.ts +129 -0
- package/dist/types/src/kvstore/GlobalKVStore.d.ts.map +1 -0
- package/dist/types/src/kvstore/kvStoreInterpreter.d.ts +22 -0
- package/dist/types/src/kvstore/kvStoreInterpreter.d.ts.map +1 -0
- package/dist/types/src/kvstore/types.d.ts +106 -0
- package/dist/types/src/kvstore/types.d.ts.map +1 -0
- package/dist/types/src/overlay-tools/Historian.d.ts +92 -0
- package/dist/types/src/overlay-tools/Historian.d.ts.map +1 -0
- package/dist/types/src/script/templates/PushDrop.d.ts +6 -5
- package/dist/types/src/script/templates/PushDrop.d.ts.map +1 -1
- package/dist/types/src/transaction/Transaction.d.ts +2 -2
- package/dist/types/src/transaction/Transaction.d.ts.map +1 -1
- package/dist/types/src/transaction/fee-models/LivePolicy.d.ts +41 -0
- package/dist/types/src/transaction/fee-models/LivePolicy.d.ts.map +1 -0
- package/dist/types/src/transaction/fee-models/index.d.ts +1 -0
- package/dist/types/src/transaction/fee-models/index.d.ts.map +1 -1
- package/dist/types/src/wallet/WalletClient.d.ts +1 -1
- package/dist/types/src/wallet/WalletClient.d.ts.map +1 -1
- package/dist/types/src/wallet/substrates/WalletWireProcessor.d.ts.map +1 -1
- package/dist/types/src/wallet/substrates/WalletWireTransceiver.d.ts.map +1 -1
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/dist/umd/bundle.js +3 -3
- package/dist/umd/bundle.js.map +1 -1
- package/docs/reference/script.md +7 -19
- package/docs/reference/transaction.md +75 -6
- package/docs/reference/wallet.md +1 -1
- package/package.json +1 -1
- package/src/kvstore/GlobalKVStore.ts +478 -0
- package/src/kvstore/LocalKVStore.ts +7 -7
- package/src/kvstore/__tests/GlobalKVStore.test.ts +965 -0
- package/src/kvstore/__tests/LocalKVStore.test.ts +72 -0
- package/src/kvstore/kvStoreInterpreter.ts +49 -0
- package/src/kvstore/types.ts +114 -0
- package/src/overlay-tools/Historian.ts +195 -0
- package/src/overlay-tools/__tests/Historian.test.ts +690 -0
- package/src/script/templates/PushDrop.ts +6 -5
- package/src/transaction/Transaction.ts +4 -4
- package/src/transaction/fee-models/LivePolicy.ts +97 -0
- package/src/transaction/fee-models/__tests/LivePolicy.test.ts +148 -0
- package/src/transaction/fee-models/index.ts +1 -0
- package/src/wallet/WalletClient.ts +50 -51
- package/src/wallet/substrates/WalletWireProcessor.ts +21 -0
- package/src/wallet/substrates/WalletWireTransceiver.ts +22 -10
|
@@ -481,6 +481,51 @@ describe('localKVStore', () => {
|
|
|
481
481
|
})
|
|
482
482
|
expect(mockWallet.relinquishOutput).not.toHaveBeenCalled()
|
|
483
483
|
})
|
|
484
|
+
|
|
485
|
+
it('should preserve original error message when createAction fails', async () => {
|
|
486
|
+
const originalErrorMessage = 'Network connection timeout while creating transaction'
|
|
487
|
+
const originalError = new Error(originalErrorMessage)
|
|
488
|
+
|
|
489
|
+
// Mock the lookupValue to return a value that differs from what we're setting
|
|
490
|
+
// to ensure set() will attempt to create a transaction
|
|
491
|
+
const mockedLor: ListOutputsResult = {
|
|
492
|
+
totalOutputs: 0,
|
|
493
|
+
outputs: [],
|
|
494
|
+
BEEF: undefined
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const lookupValueReal = kvStore['lookupValue']
|
|
498
|
+
kvStore['lookupValue'] = jest.fn().mockResolvedValue({
|
|
499
|
+
value: 'different_value', // Different from testValue to trigger createAction
|
|
500
|
+
outpoint: undefined,
|
|
501
|
+
lor: mockedLor
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
// Mock wallet.createAction to fail with a specific error
|
|
505
|
+
mockWallet.createAction.mockRejectedValue(originalError)
|
|
506
|
+
|
|
507
|
+
// Mock other required methods
|
|
508
|
+
const valueArray = Array.from(testRawValueBuffer)
|
|
509
|
+
const encryptedArray = Array.from(testEncryptedValue)
|
|
510
|
+
MockedUtils.toArray.mockReturnValue(valueArray)
|
|
511
|
+
mockWallet.encrypt.mockResolvedValue({ ciphertext: encryptedArray } as WalletEncryptResult)
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
await kvStore.set(testKey, testValue)
|
|
515
|
+
fail('Expected set() to throw an error but it succeeded')
|
|
516
|
+
} catch (error) {
|
|
517
|
+
// Verify that the thrown error contains both the contextual message and the original error
|
|
518
|
+
expect(error).toBeInstanceOf(Error)
|
|
519
|
+
const errorMessage = (error as Error).message
|
|
520
|
+
expect(errorMessage).toContain('outputs with tag')
|
|
521
|
+
expect(errorMessage).toContain('cannot be unlocked')
|
|
522
|
+
expect(errorMessage).toContain('Original error:')
|
|
523
|
+
expect(errorMessage).toContain(originalErrorMessage)
|
|
524
|
+
} finally {
|
|
525
|
+
// Restore the original method
|
|
526
|
+
kvStore['lookupValue'] = lookupValueReal
|
|
527
|
+
}
|
|
528
|
+
})
|
|
484
529
|
})
|
|
485
530
|
|
|
486
531
|
// --- Remove Method Tests ---
|
|
@@ -610,5 +655,32 @@ describe('localKVStore', () => {
|
|
|
610
655
|
expect(mockWallet.signAction).toHaveBeenCalled() // Called but failed
|
|
611
656
|
|
|
612
657
|
})
|
|
658
|
+
|
|
659
|
+
it('should preserve original error message when wallet operations fail during removal', async () => {
|
|
660
|
+
const originalErrorMessage = 'Insufficient funds to cover transaction fees'
|
|
661
|
+
const originalError = new Error(originalErrorMessage)
|
|
662
|
+
|
|
663
|
+
const existingOutpoint = 'failTxId.0'
|
|
664
|
+
const existingOutput = { outpoint: existingOutpoint, txid: 'failTxId', vout: 0, lockingScript: 's1' }
|
|
665
|
+
const mockBEEF = Buffer.from('mockBEEFFail')
|
|
666
|
+
|
|
667
|
+
// Mock wallet to have outputs but createAction fails
|
|
668
|
+
mockWallet.listOutputs.mockResolvedValue({ outputs: [existingOutput], totalOutputs: 1, BEEF: mockBEEF } as any)
|
|
669
|
+
mockWallet.createAction.mockRejectedValue(originalError)
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
await kvStore.remove(testKey)
|
|
673
|
+
fail('Expected remove() to throw an error but it succeeded')
|
|
674
|
+
} catch (error) {
|
|
675
|
+
// Verify that the thrown error contains both the contextual message and the original error
|
|
676
|
+
expect(error).toBeInstanceOf(Error)
|
|
677
|
+
const errorMessage = (error as Error).message
|
|
678
|
+
expect(errorMessage).toContain('1 outputs with tag')
|
|
679
|
+
expect(errorMessage).toContain(testKey)
|
|
680
|
+
expect(errorMessage).toContain('cannot be unlocked')
|
|
681
|
+
expect(errorMessage).toContain('Original error:')
|
|
682
|
+
expect(errorMessage).toContain(originalErrorMessage)
|
|
683
|
+
}
|
|
684
|
+
})
|
|
613
685
|
})
|
|
614
686
|
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { PushDrop } from '../script/index.js'
|
|
2
|
+
import Transaction from '../transaction/Transaction.js'
|
|
3
|
+
import * as Utils from '../primitives/utils.js'
|
|
4
|
+
import { kvProtocol } from './types.js'
|
|
5
|
+
import { InterpreterFunction } from '../overlay-tools/Historian.js'
|
|
6
|
+
import { WalletProtocol } from '../wallet/Wallet.interfaces.js'
|
|
7
|
+
|
|
8
|
+
export interface KVContext { key: string, protocolID: WalletProtocol }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* KVStore interpreter used by Historian.
|
|
12
|
+
*
|
|
13
|
+
* Validates the KVStore PushDrop tokens: [protocolID, key, value, controller, signature].
|
|
14
|
+
* Filters outputs by the provided key in the interpreter context.
|
|
15
|
+
* Produces the plaintext value for matching outputs; returns undefined otherwise.
|
|
16
|
+
*
|
|
17
|
+
* @param transaction - The transaction to inspect.
|
|
18
|
+
* @param outputIndex - The index of the output within transaction.outputs.
|
|
19
|
+
* @param ctx - { key: string, protocolID: WalletProtocol } — per-call context specifying which key to match.
|
|
20
|
+
*
|
|
21
|
+
* @returns string | undefined — the decoded KV value if the output is a valid KVStore token for the
|
|
22
|
+
* given key; otherwise undefined.
|
|
23
|
+
*/
|
|
24
|
+
export const kvStoreInterpreter: InterpreterFunction<string, KVContext> = async (transaction: Transaction, outputIndex: number, ctx?: KVContext): Promise<string | undefined> => {
|
|
25
|
+
try {
|
|
26
|
+
const output = transaction.outputs[outputIndex]
|
|
27
|
+
if (output == null || output.lockingScript == null) return undefined
|
|
28
|
+
if (ctx == null || ctx.key == null) return undefined
|
|
29
|
+
|
|
30
|
+
// Decode the KVStore token
|
|
31
|
+
const decoded = PushDrop.decode(output.lockingScript)
|
|
32
|
+
|
|
33
|
+
// Validate KVStore token format (must have 5 fields: [protocolID, key, value, controller, signature])
|
|
34
|
+
if (decoded.fields.length !== Object.keys(kvProtocol).length) return undefined
|
|
35
|
+
|
|
36
|
+
// Only return values for the given key and protocolID
|
|
37
|
+
const key = Utils.toUTF8(decoded.fields[kvProtocol.key])
|
|
38
|
+
const protocolID = Utils.toUTF8(decoded.fields[kvProtocol.protocolID])
|
|
39
|
+
if (key !== ctx.key || protocolID !== JSON.stringify(ctx.protocolID)) return undefined
|
|
40
|
+
try {
|
|
41
|
+
return Utils.toUTF8(decoded.fields[kvProtocol.value])
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// Skip non-KVStore outputs or malformed tokens
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Beef } from '../transaction/Beef.js'
|
|
2
|
+
import { PubKeyHex, WalletProtocol } from '../wallet/Wallet.interfaces.js'
|
|
3
|
+
import { WalletInterface } from '../wallet/index.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Configuration interface for GlobalKVStore operations.
|
|
7
|
+
* Defines all options for connecting to overlay services and managing KVStore behavior.
|
|
8
|
+
*/
|
|
9
|
+
export interface KVStoreConfig {
|
|
10
|
+
/** The overlay service host URL */
|
|
11
|
+
overlayHost?: string
|
|
12
|
+
/** Protocol ID for the KVStore protocol */
|
|
13
|
+
protocolID?: WalletProtocol
|
|
14
|
+
/** Service name for overlay submission */
|
|
15
|
+
serviceName?: string
|
|
16
|
+
/** Amount of satoshis for each token */
|
|
17
|
+
tokenAmount?: number
|
|
18
|
+
/** Topics for overlay submission */
|
|
19
|
+
topics?: string[]
|
|
20
|
+
/** Originator */
|
|
21
|
+
originator?: string
|
|
22
|
+
/** Wallet interface for operations */
|
|
23
|
+
wallet?: WalletInterface
|
|
24
|
+
/** Network preset for overlay services */
|
|
25
|
+
networkPreset?: 'mainnet' | 'testnet' | 'local'
|
|
26
|
+
/** Whether to accept delayed broadcast */
|
|
27
|
+
acceptDelayedBroadcast?: boolean
|
|
28
|
+
/** Description for token set */
|
|
29
|
+
tokenSetDescription?: string
|
|
30
|
+
/** Description for token update */
|
|
31
|
+
tokenUpdateDescription?: string
|
|
32
|
+
/** Description for token removal */
|
|
33
|
+
tokenRemovalDescription?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Query parameters for KVStore lookups from overlay services.
|
|
38
|
+
* Used when searching for existing key-value pairs in the network.
|
|
39
|
+
*/
|
|
40
|
+
export interface KVStoreQuery {
|
|
41
|
+
key?: string
|
|
42
|
+
controller?: PubKeyHex
|
|
43
|
+
protocolID?: WalletProtocol
|
|
44
|
+
limit?: number
|
|
45
|
+
skip?: number
|
|
46
|
+
sortOrder?: 'asc' | 'desc'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Options for configuring KVStore get operations (local processing)
|
|
51
|
+
*/
|
|
52
|
+
export interface KVStoreGetOptions {
|
|
53
|
+
/** Whether to build and include history for each entry */
|
|
54
|
+
history?: boolean
|
|
55
|
+
/** Whether to include token transaction data in results */
|
|
56
|
+
includeToken?: boolean
|
|
57
|
+
/** Service name for overlay retrieval */
|
|
58
|
+
serviceName?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface KVStoreSetOptions {
|
|
62
|
+
protocolID?: WalletProtocol
|
|
63
|
+
tokenSetDescription?: string
|
|
64
|
+
tokenUpdateDescription?: string
|
|
65
|
+
tokenAmount?: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface KVStoreRemoveOptions {
|
|
69
|
+
protocolID?: WalletProtocol
|
|
70
|
+
tokenRemovalDescription?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* KVStore entry returned from queries
|
|
75
|
+
*/
|
|
76
|
+
export interface KVStoreEntry {
|
|
77
|
+
key: string
|
|
78
|
+
value: string
|
|
79
|
+
controller: PubKeyHex
|
|
80
|
+
protocolID: WalletProtocol
|
|
81
|
+
token?: KVStoreToken
|
|
82
|
+
history?: string[]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Result structure for KVStore lookups from overlay services.
|
|
87
|
+
* Contains the transaction output information for a found key-value pair.
|
|
88
|
+
*/
|
|
89
|
+
export interface KVStoreLookupResult {
|
|
90
|
+
txid: string
|
|
91
|
+
outputIndex: number
|
|
92
|
+
outputScript: string
|
|
93
|
+
satoshis: number
|
|
94
|
+
history?: (output: any, currentDepth: number) => Promise<boolean>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Token structure containing a KVStore token from overlay services.
|
|
99
|
+
* Wraps the transaction data and metadata for a key-value pair.
|
|
100
|
+
*/
|
|
101
|
+
export interface KVStoreToken {
|
|
102
|
+
txid: string
|
|
103
|
+
outputIndex: number
|
|
104
|
+
satoshis: number
|
|
105
|
+
beef: Beef
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const kvProtocol = {
|
|
109
|
+
protocolID: 0,
|
|
110
|
+
key: 1,
|
|
111
|
+
value: 2,
|
|
112
|
+
controller: 3,
|
|
113
|
+
signature: 4
|
|
114
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import Transaction from '../transaction/Transaction.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Interpreter function signature used by Historian.
|
|
5
|
+
*
|
|
6
|
+
* Generics:
|
|
7
|
+
* - T: The decoded/typed value produced for a matching output. Returning `undefined`
|
|
8
|
+
* means “this output does not contribute to history.”
|
|
9
|
+
* - C: The per-call context passed through Historian to the interpreter. This carries
|
|
10
|
+
* any metadata needed to interpret outputs (e.g., `{ key: string }` for KVStore).
|
|
11
|
+
*
|
|
12
|
+
* Params:
|
|
13
|
+
* - tx: The transaction containing the output to interpret.
|
|
14
|
+
* - outputIndex: Index of the output within `tx.outputs`.
|
|
15
|
+
* - ctx: Optional context object of type C, supplied at [Historian.buildHistory(startTx, context)]
|
|
16
|
+
*
|
|
17
|
+
* Returns:
|
|
18
|
+
* - `T | undefined` (or a Promise thereof). `undefined` indicates a non-applicable or
|
|
19
|
+
* un-decodable output for the given context.
|
|
20
|
+
*/
|
|
21
|
+
export type InterpreterFunction<T, C = unknown> =
|
|
22
|
+
(tx: Transaction, outputIndex: number, ctx?: C) => Promise<T | undefined> | T | undefined
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Historian builds a chronological history (oldest → newest) of typed values by traversing
|
|
26
|
+
* a transaction's input ancestry and interpreting each output with a provided interpreter.
|
|
27
|
+
*
|
|
28
|
+
* Core ideas:
|
|
29
|
+
* - You provide an interpreter `(tx, outputIndex) => T | undefined` that decodes one output
|
|
30
|
+
* into your domain value (e.g., kvstore entries). If it returns `undefined`, that output
|
|
31
|
+
* contributes nothing to history.
|
|
32
|
+
* - Traversal follows `Transaction.inputs[].sourceTransaction` recursively, so callers must
|
|
33
|
+
* supply transactions whose inputs have `sourceTransaction` populated (e.g., via overlay
|
|
34
|
+
* history reconstruction).
|
|
35
|
+
* - The traversal visits each transaction once (cycle-safe) and collects interpreted values
|
|
36
|
+
* in reverse-chronological order, then returns them as chronological (oldest-first).
|
|
37
|
+
* - Optional caching support: provide a `historyCache` Map to cache complete history results
|
|
38
|
+
* and avoid re-traversing identical transaction chains with the same context.
|
|
39
|
+
*
|
|
40
|
+
* Usage:
|
|
41
|
+
* - Construct with an interpreter (and optional cache)
|
|
42
|
+
* - Call historian.buildHistory(tx, context) to get an array of values representing the history of a token over time.
|
|
43
|
+
*
|
|
44
|
+
* Example:
|
|
45
|
+
* const cache = new Map() // Optional: for caching repeated queries
|
|
46
|
+
* const historian = new Historian(interpreter, { historyCache: cache })
|
|
47
|
+
* const history = await historian.buildHistory(tipTransaction, context)
|
|
48
|
+
* // history: T[] (e.g., prior values for a protected kvstore key)
|
|
49
|
+
*
|
|
50
|
+
* Caching:
|
|
51
|
+
* - Cache keys are generated from `interpreterVersion|txid|contextKey`
|
|
52
|
+
* - Cached results are immutable snapshots to prevent external mutation
|
|
53
|
+
* - Bump `interpreterVersion` when interpreter semantics change to invalidate old cache entries
|
|
54
|
+
*/
|
|
55
|
+
export class Historian<T, C = unknown> {
|
|
56
|
+
private readonly interpreter: InterpreterFunction<T, C>
|
|
57
|
+
private readonly debug: boolean
|
|
58
|
+
|
|
59
|
+
// --- minimal cache support ---
|
|
60
|
+
private readonly historyCache?: Map<string, readonly T[]>
|
|
61
|
+
private readonly interpreterVersion: string
|
|
62
|
+
private readonly ctxKeyFn: (ctx?: C) => string
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create a new Historian instance
|
|
66
|
+
*
|
|
67
|
+
* @param interpreter - Function to interpret transaction outputs into typed values
|
|
68
|
+
* @param options - Configuration options
|
|
69
|
+
* @param options.debug - Enable debug logging (default: false)
|
|
70
|
+
* @param options.historyCache - Optional external cache for complete history results
|
|
71
|
+
* @param options.interpreterVersion - Version identifier for cache invalidation (default: 'v1')
|
|
72
|
+
* @param options.ctxKeyFn - Custom function to serialize context for cache keys (default: JSON.stringify)
|
|
73
|
+
*/
|
|
74
|
+
constructor (
|
|
75
|
+
interpreter: InterpreterFunction<T, C>,
|
|
76
|
+
options?: {
|
|
77
|
+
debug?: boolean
|
|
78
|
+
/** Optional cache for entire history results keyed by (version|txid|ctxKey) */
|
|
79
|
+
historyCache?: Map<string, readonly T[]>
|
|
80
|
+
/** Bump this if interpreter semantics change to invalidate cache */
|
|
81
|
+
interpreterVersion?: string
|
|
82
|
+
/** Deterministic, non-secret key for context. Default is JSON.stringify(ctx) */
|
|
83
|
+
ctxKeyFn?: (ctx?: C) => string
|
|
84
|
+
}
|
|
85
|
+
) {
|
|
86
|
+
this.interpreter = interpreter
|
|
87
|
+
this.debug = options?.debug ?? false
|
|
88
|
+
|
|
89
|
+
// Configure caching (all optional)
|
|
90
|
+
this.historyCache = options?.historyCache
|
|
91
|
+
this.interpreterVersion = options?.interpreterVersion ?? 'v1'
|
|
92
|
+
this.ctxKeyFn = options?.ctxKeyFn ?? ((ctx?: C) => {
|
|
93
|
+
try { return JSON.stringify(ctx ?? null) } catch { return '' }
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private historyKey (startTransaction: Transaction, context?: C): string {
|
|
98
|
+
const txid = startTransaction.id('hex')
|
|
99
|
+
const ctxKey = this.ctxKeyFn(context)
|
|
100
|
+
return `${this.interpreterVersion}|${txid}|${ctxKey}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build history by traversing input chain from a starting transaction
|
|
105
|
+
* Returns values in chronological order (oldest first)
|
|
106
|
+
*
|
|
107
|
+
* If caching is enabled, will first check for cached results matching the
|
|
108
|
+
* startTransaction and context. On cache miss, performs full traversal and
|
|
109
|
+
* stores the result for future queries.
|
|
110
|
+
*
|
|
111
|
+
* @param startTransaction - The transaction to start traversal from
|
|
112
|
+
* @param context - The context to pass to the interpreter
|
|
113
|
+
* @returns Array of interpreted values in chronological order
|
|
114
|
+
*/
|
|
115
|
+
async buildHistory (startTransaction: Transaction, context?: C): Promise<T[]> {
|
|
116
|
+
// --- minimal cache fast path ---
|
|
117
|
+
if (this.historyCache != null) {
|
|
118
|
+
const cacheKey = this.historyKey(startTransaction, context)
|
|
119
|
+
if (this.historyCache.has(cacheKey)) {
|
|
120
|
+
const cached = this.historyCache.get(cacheKey)
|
|
121
|
+
if (cached != null) {
|
|
122
|
+
if (this.debug) console.log('[Historian] History cache hit:', cacheKey)
|
|
123
|
+
// Return a shallow copy to avoid external mutation of the cached array
|
|
124
|
+
return cached.slice()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const history: T[] = []
|
|
130
|
+
const visited = new Set<string>()
|
|
131
|
+
|
|
132
|
+
// Recursively traverse input transactions to build history
|
|
133
|
+
const traverseHistory = async (transaction: Transaction): Promise<void> => {
|
|
134
|
+
const txid = transaction.id('hex')
|
|
135
|
+
|
|
136
|
+
// Prevent infinite loops
|
|
137
|
+
if (visited.has(txid)) {
|
|
138
|
+
if (this.debug) {
|
|
139
|
+
console.log(`[Historian] Skipping already visited transaction: ${txid}`)
|
|
140
|
+
}
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
visited.add(txid)
|
|
144
|
+
|
|
145
|
+
if (this.debug) {
|
|
146
|
+
console.log(`[Historian] Processing transaction: ${txid}`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check all outputs in this transaction for interpretable values
|
|
150
|
+
for (let outputIndex = 0; outputIndex < transaction.outputs.length; outputIndex++) {
|
|
151
|
+
try {
|
|
152
|
+
// Try to interpret this output
|
|
153
|
+
const interpretedValue = await Promise.resolve(this.interpreter(transaction, outputIndex, context))
|
|
154
|
+
|
|
155
|
+
if (interpretedValue !== undefined) {
|
|
156
|
+
history.push(interpretedValue)
|
|
157
|
+
if (this.debug) {
|
|
158
|
+
console.log('[Historian] Added value to history:', interpretedValue)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (this.debug) {
|
|
163
|
+
console.log(`[Historian] Failed to interpret output ${outputIndex}:`, error)
|
|
164
|
+
}
|
|
165
|
+
// Skip outputs that can't be interpreted
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Recursively traverse input transactions
|
|
170
|
+
for (const input of transaction.inputs) {
|
|
171
|
+
if (input.sourceTransaction != null) {
|
|
172
|
+
await traverseHistory(input.sourceTransaction)
|
|
173
|
+
} else if (this.debug) {
|
|
174
|
+
console.log('[Historian] Input missing sourceTransaction, skipping')
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Start traversal from the provided transaction
|
|
180
|
+
await traverseHistory(startTransaction)
|
|
181
|
+
|
|
182
|
+
// History is built in reverse chronological order during traversal,
|
|
183
|
+
// so we reverse it to return oldest-first
|
|
184
|
+
const chronological = history.reverse()
|
|
185
|
+
|
|
186
|
+
if (this.historyCache != null) {
|
|
187
|
+
const cacheKey = this.historyKey(startTransaction, context)
|
|
188
|
+
// Store an immutable snapshot to avoid accidental external mutation
|
|
189
|
+
this.historyCache.set(cacheKey, Object.freeze(chronological.slice()))
|
|
190
|
+
if (this.debug) console.log('[Historian] History cached:', cacheKey)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return chronological
|
|
194
|
+
}
|
|
195
|
+
}
|