@bsv/wallet-toolbox
Version:
BRC100 conforming wallet, wallet storage and wallet signer components
314 lines (285 loc) • 8.65 kB
text/typescript
import { MerklePath } from '@bsv/sdk'
import { arraysEqual, sdk, TableProvenTx, verifyId, verifyOneOrNone } from '../../../index.client'
import { EntityBase, EntityProvenTxReq, EntityStorage, SyncMap } from '.'
export class EntityProvenTx extends EntityBase<TableProvenTx> {
/**
* Given a txid and optionally its rawTx, create a new ProvenTx object.
*
* rawTx is fetched if not provided.
*
* Only succeeds (proven is not undefined) if a proof is confirmed for rawTx,
* and hash of rawTx is confirmed to match txid
*
* The returned ProvenTx and ProvenTxReq objects have not been added to the storage database,
* this is optional and can be done by the caller if appropriate.
*
* @param txid
* @param services
* @param rawTx
* @returns
*/
static async fromTxid(txid: string, services: sdk.WalletServices, rawTx?: number[]): Promise<ProvenTxFromTxidResult> {
const r: ProvenTxFromTxidResult = { proven: undefined, rawTx }
const chain = services.chain
if (!r.rawTx) {
const gr = await services.getRawTx(txid)
if (!gr?.rawTx)
// Failing to find anything...
return r
r.rawTx = gr.rawTx!
}
const gmpr = await services.getMerklePath(txid)
if (gmpr.merklePath && gmpr.header) {
const index = gmpr.merklePath.path[0].find(l => l.hash === txid)?.offset
if (index !== undefined) {
const api: TableProvenTx = {
created_at: new Date(),
updated_at: new Date(),
provenTxId: 0,
txid,
height: gmpr.header.height,
index,
merklePath: gmpr.merklePath.toBinary(),
rawTx: r.rawTx,
blockHash: gmpr.header.hash,
merkleRoot: gmpr.header.merkleRoot
}
r.proven = new EntityProvenTx(api)
}
}
return r
}
constructor(api?: TableProvenTx) {
const now = new Date()
super(
api || {
provenTxId: 0,
created_at: now,
updated_at: now,
txid: '',
height: 0,
index: 0,
merklePath: [],
rawTx: [],
blockHash: '',
merkleRoot: ''
}
)
}
override updateApi(): void {
/* nothing needed yet... */
}
/**
* @returns desirialized `MerklePath` object, value is cached.
*/
getMerklePath(): MerklePath {
if (!this._mp) this._mp = MerklePath.fromBinary(this.api.merklePath)
return this._mp
}
_mp?: MerklePath
get provenTxId() {
return this.api.provenTxId
}
set provenTxId(v: number) {
this.api.provenTxId = v
}
get created_at() {
return this.api.created_at
}
set created_at(v: Date) {
this.api.created_at = v
}
get updated_at() {
return this.api.updated_at
}
set updated_at(v: Date) {
this.api.updated_at = v
}
get txid() {
return this.api.txid
}
set txid(v: string) {
this.api.txid = v
}
get height() {
return this.api.height
}
set height(v: number) {
this.api.height = v
}
get index() {
return this.api.index
}
set index(v: number) {
this.api.index = v
}
get merklePath() {
return this.api.merklePath
}
set merklePath(v: number[]) {
this.api.merklePath = v
}
get rawTx() {
return this.api.rawTx
}
set rawTx(v: number[]) {
this.api.rawTx = v
}
get blockHash() {
return this.api.blockHash
}
set blockHash(v: string) {
this.api.blockHash = v
}
get merkleRoot() {
return this.api.merkleRoot
}
set merkleRoot(v: string) {
this.api.merkleRoot = v
}
override get id() {
return this.api.provenTxId
}
override set id(v: number) {
this.api.provenTxId = v
}
override get entityName(): string {
return 'provenTx'
}
override get entityTable(): string {
return 'proven_txs'
}
override equals(ei: TableProvenTx, syncMap?: SyncMap | undefined): boolean {
const eo = this.toApi()
if (
eo.txid != ei.txid ||
eo.height != ei.height ||
eo.index != ei.index ||
!arraysEqual(eo.merklePath, ei.merklePath) ||
!arraysEqual(eo.rawTx, ei.rawTx) ||
eo.blockHash !== ei.blockHash ||
eo.merkleRoot !== ei.merkleRoot
// equality does not depend on timestamps.
// || eo.created_at !== ei.created_at
// || eo.updated_at !== ei.updated_at
)
return false
if (syncMap) {
if (eo.provenTxId !== syncMap.provenTx.idMap[ei.provenTxId]) return false
} else {
if (eo.provenTxId !== ei.provenTxId) return false
}
return true
}
static async mergeFind(
storage: EntityStorage,
userId: number,
ei: TableProvenTx,
syncMap: SyncMap,
trx?: sdk.TrxToken
): Promise<{ found: boolean; eo: EntityProvenTx; eiId: number }> {
const ef = verifyOneOrNone(await storage.findProvenTxs({ partial: { txid: ei.txid }, trx }))
return {
found: !!ef,
eo: new EntityProvenTx(ef || { ...ei }),
eiId: verifyId(ei.provenTxId)
}
}
override async mergeNew(storage: EntityStorage, userId: number, syncMap: SyncMap, trx?: sdk.TrxToken): Promise<void> {
this.provenTxId = 0
// TODO: Since these records are a shared resource, the record must be validated before accepting it...
this.provenTxId = await storage.insertProvenTx(this.toApi(), trx)
}
override async mergeExisting(
storage: EntityStorage,
since: Date | undefined,
ei: TableProvenTx,
syncMap: SyncMap,
trx?: sdk.TrxToken
): Promise<boolean> {
// ProvenTxs are never updated.
return false
}
/**
* How high attempts can go before status is forced to invalid
*/
static getProofAttemptsLimit = 8
/**
* How many hours we have to try for a poof
*/
static getProofMinutes = 60
/**
* Try to create a new ProvenTx from a ProvenTxReq and GetMerkleProofResultApi
*
* Otherwise it returns undefined and updates req.status to either 'unknown', 'invalid', or 'unconfirmed'
*
* @param req
* @param gmpResult
* @returns
*/
static async fromReq(
req: EntityProvenTxReq,
gmpResult: sdk.GetMerklePathResult,
countsAsAttempt: boolean
): Promise<EntityProvenTx | undefined> {
if (!req.txid) throw new sdk.WERR_MISSING_PARAMETER('req.txid')
if (!req.rawTx) throw new sdk.WERR_MISSING_PARAMETER('req.rawTx')
if (!req.rawTx) throw new sdk.WERR_INTERNAL('rawTx must be valid')
for (const note of gmpResult.notes || []) {
req.addHistoryNote(note, true)
}
if (!gmpResult.name && !gmpResult.merklePath && !gmpResult.error) {
// Most likely offline or now services configured.
// Does not count as a proof attempt.
return undefined
}
if (!gmpResult.merklePath) {
if (req.created_at) {
const ageInMsecs = Date.now() - req.created_at.getTime()
const ageInMinutes = Math.ceil(ageInMsecs < 1 ? 0 : ageInMsecs / (1000 * 60))
if (req.attempts > EntityProvenTx.getProofAttemptsLimit && ageInMinutes > EntityProvenTx.getProofMinutes) {
// Start the process of setting transactions to 'failed'
const limit = EntityProvenTx.getProofAttemptsLimit
const { attempts } = req
req.addHistoryNote({ what: 'getMerklePathGiveUp', attempts, limit, ageInMinutes }, true)
req.notified = false
req.status = 'invalid'
}
}
return undefined
}
if (countsAsAttempt) req.attempts++
const merklePaths = Array.isArray(gmpResult.merklePath) ? gmpResult.merklePath : [gmpResult.merklePath]
for (const proof of merklePaths) {
try {
const now = new Date()
const leaf = proof.path[0].find(leaf => leaf.txid === true && leaf.hash === req.txid)
if (!leaf) {
req.addHistoryNote({ what: 'getMerklePathTxidNotFound' }, true)
throw new sdk.WERR_INTERNAL('merkle path does not contain leaf for txid')
}
const proven = new EntityProvenTx({
created_at: now,
updated_at: now,
provenTxId: 0,
txid: req.txid,
height: proof.blockHeight,
index: leaf.offset,
merklePath: proof.toBinary(),
rawTx: req.rawTx,
merkleRoot: gmpResult.header!.merkleRoot,
blockHash: gmpResult.header!.hash
})
return proven
} catch (eu: unknown) {
const { code, description } = sdk.WalletError.fromUnknown(eu)
const { attempts } = req
req.addHistoryNote({ what: 'getMerklePathProvenError', attempts, code, description }, true)
}
}
}
}
export interface ProvenTxFromTxidResult {
proven?: EntityProvenTx
rawTx?: number[]
}