Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Beef, MerklePath, Script, Transaction, UnlockingScript } from '@bsv/sdk'
import { internalizeAction } from '../internalizeAction'
import type { Wallet } from '../../../Wallet'
import { stringifyJsonRpc } from '../../../storage/remoting/BinaryJson'

describe('signer internalizeAction storage arguments', () => {
test('passes the AtomicBEEF to storage as bytes so negotiated binary JSON carries it compactly', async () => {
const tx = new Transaction()
tx.addInput({ sourceTXID: '00'.repeat(32), sourceOutputIndex: 0, unlockingScript: new UnlockingScript() })
tx.addOutput({ satoshis: 1000, lockingScript: Script.fromHex('51') })
const txid = tx.id('hex')
const beef = new Beef()
beef.mergeBump(new MerklePath(800000, [[{ offset: 0, hash: txid, txid: true }]]))
beef.mergeTransaction(tx)

const received: unknown[] = []
const wallet = {
getServices: () => ({ getChainTracker: async () => ({ isValidRootForHeight: async () => true }) }),
storage: {
internalizeAction: async (args: unknown) => {
received.push(args)
return { accepted: true, isMerge: false, txid, satoshis: 0 }
}
}
} as unknown as Wallet

await internalizeAction(wallet, { identityKey: `02${'11'.repeat(32)}`, userId: 1 }, {
tx: beef.toBinaryAtomic(txid),
outputs: [
{
outputIndex: 0,
protocol: 'basket insertion',
insertionRemittance: { basket: 'tokens', tags: [] }
}
],
description: 'internalize as bytes'
})

const sent = received[0] as { tx: unknown }
expect(sent.tx).toBeInstanceOf(Uint8Array)
expect(Array.from(sent.tx as Uint8Array)).toEqual(beef.toBinaryAtomic(txid))
const wire = JSON.parse(stringifyJsonRpc({ params: [sent] }, true))
expect(wire.params[0].tx.$bsvBinary).toBe('base64')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export async function internalizeAction(

const r: StorageInternalizeActionResult = await wallet.storage.internalizeAction({
...args,
tx: ab.toBinaryAtomic(txid)
tx: ab.toUint8ArrayAtomic(txid)
})

return r
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -445,10 +445,11 @@ async function buildSdkInputFromOutput(
unlockLen: number | undefined
): Promise<StorageCreateTransactionSdkInput> {
if (i == null && !unlockLen) throw new WERR_INTERNAL(`vin ${vin} non-fixedInput without unlockLen`)
const sourceTransaction =
const sourceRawTx =
vargs.includeAllSourceTransactions && vargs.isSignAction
? await storage.getRawTxOfKnownValidTransaction(o.txid)
: undefined
const sourceTransaction = sourceRawTx == null ? undefined : Uint8Array.from(sourceRawTx)
return {
vin,
sourceTxid: o.txid!,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,6 @@ export async function listOutputs(
await hydrateKnexWalletOutput(dsk, output, vargs, labelsByTransactionId, tagsByOutputId, beef, trx)
)
}
if (vargs.includeTransactions) result.BEEF = beef.toBinary()
if (vargs.includeTransactions) result.BEEF = beef.toUint8Array()
return result
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ import { pruneBeefForTxids } from '../../utility/beefForTxids'
const syncChunkResponseRetryLimit = 4
const minimumSyncChunkRoughSize = 64 * 1024

/** Hold proof data as bytes so negotiated binary JSON carries it compactly. */
function withBeefBytes<T extends { inputBEEF?: number[] | Uint8Array }>(args: T): T {
if (args.inputBEEF == null || args.inputBEEF instanceof Uint8Array) return args
return { ...args, inputBEEF: Uint8Array.from(args.inputBEEF) }
}

function isSyncChunkResponseTooLarge(error: unknown): boolean {
return error instanceof Error && /WalletStorageClient rpcCall: network error 413(?:\s|$)/.test(error.message)
}
Expand Down Expand Up @@ -499,13 +505,13 @@ export abstract class StorageClientBase implements WalletStorageProvider {
if (pruned != null) {
args = {
...args,
inputBEEF: pruned.toBinary()
inputBEEF: pruned.toUint8Array()
}
}
}
}
}
return await this.rpcCall<StorageCreateActionResult>('createAction', [auth, args])
return await this.rpcCall<StorageCreateActionResult>('createAction', [auth, withBeefBytes(args)])
}

/**
Expand All @@ -523,14 +529,17 @@ export abstract class StorageClientBase implements WalletStorageProvider {
}

async prepareNoSendExpiry(auth: AuthId, args: ValidCreateActionArgs): Promise<StoragePrepareNoSendExpiryResult> {
return await this.rpcCall<StoragePrepareNoSendExpiryResult>('prepareNoSendExpiry', [auth, args])
return await this.rpcCall<StoragePrepareNoSendExpiryResult>('prepareNoSendExpiry', [auth, withBeefBytes(args)])
}

async activateNoSendExpiry(
auth: AuthId,
args: StorageActivateNoSendExpiryArgs
): Promise<StorageActivateNoSendExpiryResult> {
return await this.rpcCall<StorageActivateNoSendExpiryResult>('activateNoSendExpiry', [auth, args])
return await this.rpcCall<StorageActivateNoSendExpiryResult>('activateNoSendExpiry', [
auth,
{ ...args, target: withBeefBytes(args.target) }
])
}

async armNoSendExpiry(auth: AuthId, args: StorageArmNoSendExpiryArgs): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
SYNC_TRANSFER_MAX_BYTES,
SYNC_TRANSFER_PART_BYTES
} from './SyncTransfer'
import { syncChunkBinary } from './syncChunkBinary'
import { syncChunkBinary, tableRowsBinary } from './syncChunkBinary'
/**
* StorageServer.ts
*
Expand Down Expand Up @@ -186,11 +186,23 @@ const pagedLimitArgument = new Map<string, number>([
['findProvenTxReqs', 0]
])

const binaryRowResults = new Map<string, 'outputs' | 'provenTxReqs'>([
['findOutputsAuth', 'outputs'],
['findProvenTxReqs', 'provenTxReqs']
])

interface RpcDispatchResult {
found: boolean
result?: unknown
}

/** Copy a result's schema-defined byte fields for the already negotiated binary JSON codec. */
function binaryRpcResult(method: string, result: unknown): unknown {
if (method === 'getSyncChunk') return syncChunkBinary(result as SyncChunk)
const table = binaryRowResults.get(method)
return table != null && Array.isArray(result) ? tableRowsBinary(table, result) : result
}

function requiredAuthenticatedIdentityKey(req: Request): string {
const identityKey = (req as AuthRequest).auth?.identityKey
if (typeof identityKey !== 'string' || identityKey.trim() === '' || identityKey === 'unknown') {
Expand Down Expand Up @@ -656,8 +668,7 @@ export class StorageServer {
400
)
}
const result =
useBinary && method === 'getSyncChunk' ? syncChunkBinary(dispatch.result as SyncChunk) : dispatch.result
const result = useBinary ? binaryRpcResult(method, dispatch.result) : dispatch.result
// JSON-RPC success responses must carry a result member. Preserve an
// explicit null for void storage methods rather than letting
// JSON.stringify silently omit an undefined result.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { BEEF_V1, Beef, Script, Transaction, UnlockingScript, Validation, type WalletInterface } from '@bsv/sdk'
import { StorageClientBase } from '../StorageClientBase'
import { stringifyJsonRpc } from '../BinaryJson'

class CapturingStorageClient extends StorageClientBase {
calls: Array<{ method: string; params: unknown[] }> = []
Expand Down Expand Up @@ -93,8 +94,92 @@ describe('StorageClientBase createAction inputBEEF pruning', () => {
await client.createAction(auth, args)

const sentArgs = client.calls[0].params[1] as Validation.ValidCreateActionArgs
expect(sentArgs).toBe(args)
expect(sentArgs.inputBEEF).toBe(originalBytes)
expect(sentArgs.inputBEEF).toBeInstanceOf(Uint8Array)
expect(Array.from(sentArgs.inputBEEF!)).toEqual(Array.from(originalBytes!))
expect(args.inputBEEF).toBe(originalBytes)
})

test('passes inputBEEF that is already bytes through unchanged', async () => {
const required = makeTransaction(1000)
const beef = new Beef()
beef.mergeTransaction(required)
const args = Validation.validateCreateActionArgs({
description: 'forward byte proof data',
inputs: [
{
outpoint: `${required.id('hex')}.0`,
unlockingScript: '00',
inputDescription: 'declared byte input'
}
],
inputBEEF: beef.toUint8Array(),
outputs: [{ satoshis: 1, lockingScript: '51', outputDescription: 'replacement output' }]
})
const client = new CapturingStorageClient({} as WalletInterface, 'https://storage.example.test')

await client.createAction(auth, args)

expect(client.calls[0].params[1]).toBe(args)
})

test('sends pruned inputBEEF as bytes that negotiated binary JSON tags', async () => {
const required = makeTransaction(1000)
const beef = new Beef()
beef.mergeTransaction(required)
beef.mergeTransaction(makeTransaction(2000))
const args = Validation.validateCreateActionArgs({
description: 'prune and send bytes',
inputs: [
{
outpoint: `${required.id('hex')}.0`,
unlockingScript: '00',
inputDescription: 'declared input'
}
],
inputBEEF: beef.toBinary(),
outputs: [{ satoshis: 1, lockingScript: '51', outputDescription: 'replacement output' }]
})
const client = new CapturingStorageClient({} as WalletInterface, 'https://storage.example.test')

await client.createAction(auth, args)

const sentArgs = client.calls[0].params[1] as Validation.ValidCreateActionArgs
expect(sentArgs.inputBEEF).toBeInstanceOf(Uint8Array)
const wire = JSON.parse(stringifyJsonRpc({ params: client.calls[0].params }, true))
expect(wire.params[1].inputBEEF.$bsvBinary).toBe('base64')
})

test('sends no-send expiry inputBEEF as bytes', async () => {
const required = makeTransaction(1000)
const beef = new Beef()
beef.mergeTransaction(required)
const target = Validation.validateCreateActionArgs({
description: 'expiry target with proof data',
inputs: [
{
outpoint: `${required.id('hex')}.0`,
unlockingScript: '00',
inputDescription: 'declared input'
}
],
inputBEEF: beef.toBinary(),
outputs: [{ satoshis: 1, lockingScript: '51', outputDescription: 'replacement output' }]
})
const client = new CapturingStorageClient({} as WalletInterface, 'https://storage.example.test')

await client.prepareNoSendExpiry(auth, target)
await client.activateNoSendExpiry(auth, {
target,
fundingReference: 'ref',
fundingTxid: '33'.repeat(32),
anchorVout: 0
})

expect((client.calls[0].params[1] as Validation.ValidCreateActionArgs).inputBEEF).toBeInstanceOf(Uint8Array)
expect(
(client.calls[1].params[1] as { target: Validation.ValidCreateActionArgs }).target.inputBEEF
).toBeInstanceOf(Uint8Array)
expect(target.inputBEEF).not.toBeInstanceOf(Uint8Array)
})

test('preserves BEEF V1 when client-side pruning is required', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { parseJsonRpc } from '../BinaryJson'
import { validateSyncChunkEntities } from '../entityValidationHelpers'
import { validateEntities, validateSyncChunkEntities } from '../entityValidationHelpers'
import { type Request, type Response } from 'express'
import { TelemetryEvent, WalletLoggerInterface, Transaction, Script, MerklePath } from '@bsv/sdk'
import { toBinaryBaseBlockHeader } from '../../../services/Services'
Expand Down Expand Up @@ -178,6 +178,32 @@ describe('StorageServer JSON-RPC boundary', () => {
expect(chunk.provenTxs[0].rawTx).toBe(bytes)
})

test.each([false, true])('encodes only declared row bytes when binary is negotiated: %s', async binary => {
const bytes = Array.from({ length: 2048 }, (_, i) => i % 256)
const time = { created_at: new Date(), updated_at: new Date() }
const outputs = [{ ...time, outputId: 1, lockingScript: bytes, extraNumbers: bytes }]
const reqs = [{ ...time, provenTxReqId: 1, rawTx: bytes, inputBEEF: bytes, extraNumbers: bytes }]
const server = makeServer({ findOutputsAuth: async () => outputs, findProvenTxReqsAuth: async () => reqs })
for (const { method, params, rows, fields } of [
{ method: 'findOutputsAuth', params: [{ identityKey: 'alice' }, { partial: {} }], rows: outputs,
fields: ['lockingScript'] },
{ method: 'findProvenTxReqs', params: [{ partial: {} }], rows: reqs, fields: ['rawTx', 'inputBEEF'] }
]) {
const captured = makeResponse()
await invoke(server, 'handleRpcRequest', makeRequest({ jsonrpc: '2.0', method, params, id: 1 },
binary ? { [BINARY_ENCODING_HEADER]: BINARY_ENCODING } : {}), captured.response)
expect(captured.statusCode).toBe(200)
for (const field of fields) {
if (binary) expect(captured.body.result[0][field].$bsvBinary).toBe('base64')
else expect(captured.body.result[0][field]).toEqual(bytes)
}
expect(captured.body.result[0].extraNumbers).toEqual(bytes)
expect(validateEntities(parseJsonRpc(JSON.stringify(captured.body), binary).result)).toEqual(rows)
}
expect(outputs[0].lockingScript).toBe(bytes)
expect(reqs[0].rawTx).toBe(bytes)
})

test('accounts for HTML escaping when enforcing the response-size ceiling', async () => {
const server = makeServer({ getSettings: () => ({ value: '<'.repeat(25) }) }, { maxRpcResponseBytes: 100 })
const captured = makeResponse()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,26 @@ function isLargeByteArray(value: unknown): value is number[] {
return true
}

/** Copy one table's schema-defined byte fields for the already negotiated binary JSON codec. */
export function tableRowsBinary(
table: keyof typeof binaryFields,
rows: Array<Record<string, unknown>>
): Array<Record<string, unknown>> {
return rows.map(row => {
const copy = { ...row }
for (const field of binaryFields[table]) {
if (isLargeByteArray(copy[field])) copy[field] = Uint8Array.from(copy[field])
}
return copy
})
}

/** Copy schema-defined byte fields for the already negotiated binary JSON codec. */
export function syncChunkBinary(chunk: SyncChunk): Record<string, unknown> {
const result: Record<string, unknown> = { ...chunk }
for (const [name, fields] of Object.entries(binaryFields)) {
for (const name of Object.keys(binaryFields) as Array<keyof typeof binaryFields>) {
const rows: unknown = Reflect.get(chunk, name)
if (!Array.isArray(rows)) continue
result[name] = rows.map((row: Record<string, unknown>) => {
const copy = { ...row }
for (const field of fields) {
if (isLargeByteArray(copy[field])) copy[field] = Uint8Array.from(copy[field])
}
return copy
})
if (Array.isArray(rows)) result[name] = tableRowsBinary(name, rows)
}
return result
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import {
ListOutputsArgs,
OriginatorDomainNameStringUnder250Bytes,
OutputTagStringUnder300Bytes,
Validation,
WalletOutput
} from '@bsv/sdk'
import { StorageKnex } from '../../../src/storage/StorageKnex'
import { _tu, TestWalletProviderNoSetup } from '../../utils/TestUtilsWalletStorage'
import path from 'node:path'

Expand Down Expand Up @@ -257,6 +259,16 @@ describe('listOutputs test', () => {
}
})

test('8b_BEEF from Knex storage is bytes so negotiated binary JSON carries it compactly', async () => {
for (const { storage } of ctxs) {
if (!(storage.getActive() instanceof StorageKnex)) continue
const vargs = Validation.validateListOutputsArgs({ basket: 'default', include: 'entire transactions' })
const r = await storage.listOutputs(vargs)
expect(r.BEEF).toBeInstanceOf(Uint8Array)
expect(Beef.fromBinary(r.BEEF!).txs.length).toBeGreaterThan(0)
}
})

test('9_labels for babbage_protocol_perm', async () => {
for (const { wallet } of ctxs) {
{
Expand Down
Loading