@bsv/wallet-toolbox
Version:
BRC100 conforming wallet, wallet storage and wallet signer components
605 lines (517 loc) • 21.7 kB
text/typescript
import { Knex } from 'knex'
import * as bsv from '@bsv/sdk'
import { createSyncMap, sdk, SyncMap, TableTransaction } from '../../../../../src'
import { TestUtilsWalletStorage as _tu, TestWalletNoSetup } from '../../../../../test/utils/TestUtilsWalletStorage'
import { EntityTransaction } from '../EntityTransaction'
describe('Transaction class method tests', () => {
jest.setTimeout(99999999)
const env = _tu.getEnv('test')
const ctxs: TestWalletNoSetup[] = []
const ctxs2: TestWalletNoSetup[] = []
beforeAll(async () => {
if (env.runMySQL) {
ctxs.push(await _tu.createLegacyWalletMySQLCopy('transactionTests'))
ctxs2.push(await _tu.createLegacyWalletMySQLCopy('transactionTests2'))
}
ctxs.push(await _tu.createLegacyWalletSQLiteCopy('transactionTests'))
ctxs2.push(await _tu.createLegacyWalletSQLiteCopy('transactionTests2'))
})
afterAll(async () => {
// Destroy both sets of database contexts
for (const ctx of ctxs) {
await ctx.storage.destroy()
}
for (const ctx of ctxs2) {
await ctx.storage.destroy()
}
})
// Test: Constructor with default values
test('0_creates_instance_with_default_values', () => {
const tx = new EntityTransaction()
const now = new Date()
expect(tx.transactionId).toBe(0)
expect(tx.userId).toBe(0)
expect(tx.txid).toBe('')
expect(tx.status).toBe('unprocessed')
expect(tx.reference).toBe('')
expect(tx.satoshis).toBe(0)
expect(tx.description).toBe('')
expect(tx.isOutgoing).toBe(false)
expect(tx.rawTx).toBeUndefined()
expect(tx.inputBEEF).toBeUndefined()
expect(tx.created_at).toBeInstanceOf(Date)
expect(tx.updated_at).toBeInstanceOf(Date)
expect(tx.created_at.getTime()).toBeLessThanOrEqual(now.getTime())
expect(tx.updated_at.getTime()).toBeLessThanOrEqual(now.getTime())
})
// Test: Constructor with provided API object
test('1_creates_instance_with_provided_api_object', () => {
const now = new Date()
const apiObject: TableTransaction = {
transactionId: 123,
userId: 456,
txid: 'testTxid',
status: 'completed',
reference: 'testReference',
satoshis: 789,
description: 'testDescription',
isOutgoing: true,
rawTx: [1, 2, 3],
inputBEEF: [4, 5, 6],
created_at: now,
updated_at: now
}
const tx = new EntityTransaction(apiObject)
expect(tx.transactionId).toBe(123)
expect(tx.userId).toBe(456)
expect(tx.txid).toBe('testTxid')
expect(tx.status).toBe('completed')
expect(tx.reference).toBe('testReference')
expect(tx.satoshis).toBe(789)
expect(tx.description).toBe('testDescription')
expect(tx.isOutgoing).toBe(true)
expect(tx.rawTx).toEqual([1, 2, 3])
expect(tx.inputBEEF).toEqual([4, 5, 6])
expect(tx.created_at).toBe(now)
expect(tx.updated_at).toBe(now)
})
// Test: Getters and setters
test('2_getters_and_setters_work_correctly', () => {
const tx = new EntityTransaction()
const now = new Date()
tx.transactionId = 123
tx.userId = 456
tx.txid = 'testTxid'
tx.status = 'processed' as sdk.TransactionStatus
tx.reference = 'testReference'
tx.satoshis = 789
tx.description = 'testDescription'
tx.isOutgoing = true
tx.rawTx = [1, 2, 3]
tx.inputBEEF = [4, 5, 6]
tx.created_at = now
tx.updated_at = now
// New setters
tx.version = 2
tx.lockTime = 5000
expect(tx.transactionId).toBe(123)
expect(tx.userId).toBe(456)
expect(tx.txid).toBe('testTxid')
expect(tx.status).toBe('processed')
expect(tx.reference).toBe('testReference')
expect(tx.satoshis).toBe(789)
expect(tx.description).toBe('testDescription')
expect(tx.isOutgoing).toBe(true)
expect(tx.rawTx).toEqual([1, 2, 3])
expect(tx.inputBEEF).toEqual([4, 5, 6])
expect(tx.created_at).toBe(now)
expect(tx.updated_at).toBe(now)
// Check new properties
expect(tx.version).toBe(2) // Ensure version is set correctly
expect(tx.lockTime).toBe(5000) // Ensure lockTime is set correctly
})
// Test: `getBsvTx` returns parsed transaction
test('3_getBsvTx_returns_parsed_transaction', () => {
const rawTx = Uint8Array.from([1, 2, 3])
const tx = new EntityTransaction({
rawTx: Array.from(rawTx)
} as TableTransaction)
const bsvTx = tx.getBsvTx()
expect(bsvTx).toBeInstanceOf(bsv.Transaction)
})
// Test: `getBsvTx` returns undefined if rawTx is not set
test('4_getBsvTx_returns_undefined_if_no_rawTx', () => {
const tx = new EntityTransaction()
const bsvTx = tx.getBsvTx()
expect(bsvTx).toBeUndefined()
})
// Test: `getBsvTxIns` returns parsed inputs
test('5_getBsvTxIns_returns_inputs', () => {
const rawTx = Uint8Array.from([1, 2, 3])
const tx = new EntityTransaction({
rawTx: Array.from(rawTx)
} as TableTransaction)
const inputs = tx.getBsvTxIns()
expect(inputs).toBeInstanceOf(Array)
})
// Test: getInputs combines spentBy and rawTx inputs
test('6_getInputs_combines_spentBy_and_rawTx_inputs', async () => {
for (const { activeStorage } of ctxs) {
// Insert the transaction into the database
const txData = await _tu.insertTestTransaction(activeStorage, undefined, true)
const tx = new EntityTransaction(txData.tx)
// Assign rawTx to simulate transaction inputs
const rawTx = Uint8Array.from([1, 2, 3])
tx.rawTx = Array.from(rawTx)
// Insert test outputs with spentBy linked to the transaction
const output1 = await _tu.insertTestOutput(activeStorage, tx, 0, 100)
await activeStorage.updateOutput(output1.outputId, {
spentBy: tx.transactionId
})
const output2 = await _tu.insertTestOutput(activeStorage, tx, 1, 200)
await activeStorage.updateOutput(output2.outputId, {
spentBy: tx.transactionId
})
// Debugging: Log inserted outputs
const outputs = await activeStorage.findOutputs({
partial: { spentBy: tx.transactionId }
})
//console.log('Inserted Outputs:', outputs)
// Get inputs from the transaction
const inputs = await tx.getInputs(activeStorage)
//console.log('Transaction Inputs:', inputs)
// Validate the inputs
expect(inputs).toHaveLength(2)
expect(inputs).toEqual(
expect.arrayContaining([
expect.objectContaining({ vout: 0, satoshis: 100 }),
expect.objectContaining({ vout: 1, satoshis: 200 })
])
)
}
})
// Test: 'mergeExisting' updates when ei updated at is newer
/*****************************************************************************************************/
// Actually currently fails because mergeExisting is currently setting the date to the current date
// rather than the udpated_at from the incoming entity
/*****************************************************************************************************/
test('9_mergeExisting_updates_when_ei_updated_at_is_newer', async () => {
for (const { activeStorage } of ctxs) {
// Insert a test transaction into the database
const txData = await _tu.insertTestTransaction(activeStorage, undefined, true)
// Create the `Transaction` instance with an earlier updated_at timestamp
const tx = new EntityTransaction({
...txData.tx,
updated_at: new Date(2022, 1, 1)
})
// Create an incoming entity object (`ei`) with a newer updated_at timestamp
const ei: TableTransaction = {
...txData.tx,
updated_at: new Date(2023, 1, 1),
txid: 'newTxId'
}
const syncMap = createSyncMap()
syncMap.transaction.idMap = { 456: 123 }
syncMap.transaction.count = 1
const expectedMergeUpdatedAt = Math.max(ei.updated_at.getTime(), tx.updated_at.getTime())
// Execute `mergeExisting`
const result = await tx.mergeExisting(activeStorage, new Date(), ei, syncMap)
// Validate the result and check that the transaction was updated in the database
expect(result).toBe(true)
const updatedTx = await activeStorage.findTransactions({
partial: { transactionId: tx.transactionId }
})
expect(updatedTx[0]?.txid).toBe('newTxId')
// Currently expecting current time and date, but should be the updated_at from the incoming entity
const updatedAtTime = updatedTx[0]?.updated_at.getTime()
expect(updatedAtTime).toBe(expectedMergeUpdatedAt)
}
})
// Test: getBsvTx handles undefined rawTx
test('10_getBsvTx_handles_undefined_rawTx', () => {
const tx = new EntityTransaction() // No rawTx provided
const bsvTx = tx.getBsvTx()
expect(bsvTx).toBeUndefined()
})
// Test: getInputs handles storage lookups and input merging
test('11_getInputs_handles_storage_lookups_and_input_merging', async () => {
for (const { activeStorage } of ctxs) {
// Insert a test transaction into the database
const { tx } = await _tu.insertTestTransaction(activeStorage, undefined, true)
// Create a Transaction instance with the inserted transaction's data
const transaction = new EntityTransaction(tx)
// Insert known inputs into the database and set the `spentBy` column to the transaction ID
const input1 = await _tu.insertTestOutput(activeStorage, tx, 0, 100) // vout = 0
const input2 = await _tu.insertTestOutput(activeStorage, tx, 2, 200) // vout = 2
input1.spentBy = tx.transactionId
input2.spentBy = tx.transactionId
// Update the outputs in the database to reflect `spentBy`
await activeStorage.updateOutput(input1.outputId, input1)
await activeStorage.updateOutput(input2.outputId, input2)
// Simulate external input for rawTx parsing
const externalInput = await _tu.insertTestOutput(
activeStorage,
tx, // Reference the same transaction
3, // vout = 3
150 // Satoshis
)
// Assign rawTx to the transaction and simulate `getBsvTxIns` behavior
transaction.rawTx = Array.from(Uint8Array.from([1, 2, 3]))
transaction.getBsvTxIns = () => [
{
sourceTXID: externalInput.txid,
sourceOutputIndex: 3
} as bsv.TransactionInput
]
// Call `getInputs` to retrieve and merge inputs
const inputs = await transaction.getInputs(activeStorage)
// Validate the merged inputs
expect(inputs).toHaveLength(3) // Known inputs + external input
expect(inputs).toEqual(
expect.arrayContaining([
expect.objectContaining({ outputId: input1.outputId }),
expect.objectContaining({ outputId: input2.outputId }),
expect.objectContaining({ txid: externalInput.txid, vout: 3 })
])
)
}
})
// Test: getProvendTx retrieves proven transaction
test('15_getProvenTx_retrieves_proven_transaction', async () => {
for (const { activeStorage } of ctxs) {
// Insert a test proven transaction into the storage
const provenTx = await _tu.insertTestProvenTx(activeStorage)
// Create a Transaction instance with a valid provenTxId
const tx = new EntityTransaction({
provenTxId: provenTx.provenTxId
} as TableTransaction)
// Retrieve the ProvenTx using the getProvenTx method
const retrievedProvenTx = await tx.getProvenTx(activeStorage)
// Assert the retrieved ProvenTx is defined and matches the expected values
expect(retrievedProvenTx).toBeDefined()
expect(retrievedProvenTx?.provenTxId).toBe(provenTx.provenTxId)
}
})
// Test: getProvenTx returns undefined when provenTxId is not set
test('16_getProvenTx_returns_undefined_when_provenTxId_is_not_set', async () => {
for (const { activeStorage } of ctxs) {
// Create a Transaction instance with no provenTxId
const tx = new EntityTransaction()
// Attempt to retrieve a ProvenTx
const retrievedProvenTx = await tx.getProvenTx(activeStorage)
// Assert the result is undefined
expect(retrievedProvenTx).toBeUndefined()
}
})
// Test: getProvenTx returns undefined when no matching ProvenTx is found
test('17_getProvenTx_returns_undefined_when_no_matching_ProvenTx_is_found', async () => {
for (const { activeStorage } of ctxs) {
// Create a Transaction instance with a provenTxId that doesn't exist in storage
const tx = new EntityTransaction({ provenTxId: 9999 } as TableTransaction) // Use an ID unlikely to exist
// Attempt to retrieve a ProvenTx
const retrievedProvenTx = await tx.getProvenTx(activeStorage)
// Assert the result is undefined
expect(retrievedProvenTx).toBeUndefined()
}
})
// Test: getInputs merges known inputs correctly
test('18_getInputs_merges_known_inputs_correctly', async () => {
for (const { activeStorage } of ctxs) {
// Step 1: Insert a Transaction
const { tx } = await _tu.insertTestTransaction(activeStorage, undefined, true)
// Step 2: Insert outputs associated with the transaction
const output1 = await _tu.insertTestOutput(activeStorage, tx, 0, 100) // vout = 0
const output2 = await _tu.insertTestOutput(activeStorage, tx, 1, 200) // vout = 1
// Step 3: Create a Transaction instance with rawTx
const rawTx = Uint8Array.from([1, 2, 3]) // Example raw transaction
const transaction = new EntityTransaction({
...tx,
rawTx: Array.from(rawTx)
} as TableTransaction)
// Step 4: Simulate rawTx inputs
transaction.getBsvTxIns = () => [
{
sourceTXID: output1.txid,
sourceOutputIndex: output1.vout
} as bsv.TransactionInput,
{
sourceTXID: output2.txid,
sourceOutputIndex: output2.vout
} as bsv.TransactionInput
]
// Step 5: Call `getInputs` to retrieve and merge known inputs
const inputs = await transaction.getInputs(activeStorage)
// Step 6: Assertions
expect(inputs).toHaveLength(2) // Ensure both outputs are retrieved
expect(inputs).toEqual(
expect.arrayContaining([
expect.objectContaining({ outputId: output1.outputId }), // vout = 0
expect.objectContaining({ outputId: output2.outputId }) // vout = 1
])
)
}
})
// Test: getVersion returns API version
test('19_get_version_returns_api_version', () => {
const tx = new EntityTransaction({ version: 2 } as TableTransaction)
expect(tx.version).toBe(2)
})
// Test: getLockTime returns API lockTime
test('20_get_lockTime_returns_api_lockTime', () => {
const tx = new EntityTransaction({ lockTime: 500 } as TableTransaction)
expect(tx.lockTime).toBe(500)
})
// Test: set id updates transactionId
test('21_set_id_updates_transactionId', () => {
const tx = new EntityTransaction()
tx.id = 123
expect(tx.transactionId).toBe(123)
})
// Test: get entityName returns correct value
test('22_get_entityName_returns_correct_value', () => {
const tx = new EntityTransaction()
expect(tx.entityName).toBe('transaction')
})
// Test: get entityTable returns correct value
test('23_get_entityTable_returns_correct_value', () => {
const tx = new EntityTransaction()
expect(tx.entityTable).toBe('transactions')
})
// Test: `equals` returns false for mismatched other properties
test('25_equals_returns_false_for_mismatched_other_properties', async () => {
for (const { activeStorage } of ctxs) {
// Insert a test transaction to use as the baseline
const txData = await _tu.insertTestTransaction(activeStorage, undefined, true)
const syncMap = createSyncMap()
syncMap.transaction.idMap = {
[txData.tx.transactionId]: txData.tx.transactionId
}
syncMap.transaction.count = 1
const tx = new EntityTransaction({
...txData.tx, // Base transaction
version: 2,
lockTime: 500,
satoshis: 789,
txid: 'txid1',
rawTx: [1, 2, 3],
inputBEEF: [4, 5, 6],
description: 'desc1',
status: 'completed',
reference: 'ref1'
} as TableTransaction)
const other = {
transactionId: txData.tx.transactionId, // Matching transactionId
reference: 'ref1', // Matching reference
version: 1, // Different version
lockTime: 100, // Different lockTime
status: 'unprocessed', // Different status
satoshis: 100, // Different satoshis
description: 'desc2', // Different description
txid: 'txid2', // Different txid
rawTx: [7, 8, 9], // Different rawTx
inputBEEF: [10, 11, 12] // Different inputBEEF
} as TableTransaction
expect(tx.equals(other, syncMap)).toBe(false) // Should return false due to mismatched properties
}
})
// Test: `getInputs` handles known and unknown inputs
test('26_getInputs_handles_known_and_unknown_inputs', async () => {
for (const { activeStorage } of ctxs) {
// Step 1: Insert a Transaction into the database
const { tx } = await _tu.insertTestTransaction(activeStorage, undefined, true)
// Step 2: Insert test outputs associated with the transaction
const output1 = await _tu.insertTestOutput(activeStorage, tx, 0, 100) // vout = 0, satoshis = 100
const output2 = await _tu.insertTestOutput(activeStorage, tx, 1, 200) // vout = 1, satoshis = 200
// Step 3: Simulate rawTx inputs directly without creating a Transaction instance
const rawTxInputs = [
{ sourceTXID: tx.txid, sourceOutputIndex: 0 },
{ sourceTXID: tx.txid, sourceOutputIndex: 1 }
]
// Step 4: Query the inputs from storage individually
const inputs = await Promise.all(
rawTxInputs.map(input =>
activeStorage.findOutputs({
partial: {
transactionId: tx.transactionId,
vout: input.sourceOutputIndex
}
})
)
)
// Flatten the array of inputs (since `findOutputs` likely returns arrays for each query)
const flattenedInputs = inputs.flat()
// Step 5: Assertions
expect(flattenedInputs).toHaveLength(2) // Ensure both outputs are retrieved
expect(flattenedInputs).toEqual(
expect.arrayContaining([
expect.objectContaining({ outputId: output1.outputId }), // vout = 0
expect.objectContaining({ outputId: output2.outputId }) // vout = 1
])
)
}
})
// Test: equals identifies matching entities
test('27_equals_identifies_matching_entities', async () => {
const ctx1 = ctxs[0]
const ctx2 = ctxs2[0]
// Insert a transaction into the first database
const tx1 = new EntityTransaction({
transactionId: 405,
userId: 1,
txid: 'txid1',
created_at: new Date('2023-01-01'),
updated_at: new Date('2023-01-02'),
status: 'completed',
reference: 'ref1',
isOutgoing: true,
satoshis: 789,
description: 'desc1',
version: 2,
lockTime: 500,
rawTx: [1, 2, 3],
inputBEEF: [4, 5, 6]
})
await ctx1.activeStorage.insertTransaction(tx1.toApi())
// Insert a matching transaction into the second database
const tx2 = new EntityTransaction({
transactionId: 405,
userId: 1, // Matching userId
txid: 'txid1', // Matching txid
created_at: new Date('2023-01-01'),
updated_at: new Date('2023-01-02'),
status: 'completed', // Matching status
reference: 'ref1', // Matching reference
isOutgoing: true, // Matching isOutgoing
satoshis: 789, // Matching satoshis
description: 'desc1', // Matching description
version: 2, // Matching version
lockTime: 500, // Matching lockTime
rawTx: [1, 2, 3], // Matching rawTx
inputBEEF: [4, 5, 6] // Matching inputBEEF
})
await ctx2.activeStorage.insertTransaction(tx2.toApi())
const syncMap = createSyncMap()
syncMap.transaction.idMap = { [tx1.transactionId]: tx2.transactionId }
syncMap.transaction.count = 1
// Verify the transactions match
expect(tx1.equals(tx2.toApi(), syncMap)).toBe(true)
})
// Test: `equals` identifies non-matching entities
test('28_equals_identifies_non_matching_entities', async () => {
const ctx1 = ctxs[0]
const ctx2 = ctxs2[0]
// Insert a transaction into the first database
const tx1 = new EntityTransaction({
transactionId: 303,
userId: 1,
txid: 'tx456',
created_at: new Date('2023-01-01'),
updated_at: new Date('2023-01-02'),
status: 'unprocessed', // Default status
reference: 'ref125',
isOutgoing: false, // Default isOutgoing
satoshis: 0, // Default satoshis
description: '' // Default description
})
await ctx1.activeStorage.insertTransaction(tx1.toApi())
// Insert a non-matching transaction into the second database
const tx2 = new EntityTransaction({
transactionId: 304,
userId: 1,
txid: 'tx789',
created_at: new Date('2023-01-01'),
updated_at: new Date('2023-01-02'),
status: 'unprocessed', // Default status
reference: 'ref126',
isOutgoing: false, // Default isOutgoing
satoshis: 0, // Default satoshis
description: '' // Default description
})
await ctx2.activeStorage.insertTransaction(tx2.toApi())
const syncMap = createSyncMap()
syncMap.transaction.idMap = { [tx1.transactionId]: tx2.transactionId }
syncMap.transaction.count = 1
// Verify the transactions do not match
expect(tx1.equals(tx2.toApi(), syncMap)).toBe(false)
})
})