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
5 changes: 5 additions & 0 deletions docs/packages/wallet/wallet-toolbox-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ The portable local controller coalesces stale height refresh and immutable
object loads, applies failed-load backoff, and validates through the asynchronous
`InlineBulkFileDataValidator` without importing Node worker or filesystem code.

BEEF requests and schema-declared storage response bytes use the existing
negotiated compact binary JSON codec. Legacy peers retain numeric-array JSON
and identical transaction/proof bytes. Authentication and payload ceilings
remain unchanged; upgrade both clients and active storage for the savings.

## Backup and recovery

Recover both key material and wallet records; a manager snapshot or device
Expand Down
5 changes: 5 additions & 0 deletions docs/packages/wallet/wallet-toolbox-mobile.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ The portable local controller coalesces stale height refresh and immutable
object loads, applies failed-load backoff, and validates through the asynchronous
`InlineBulkFileDataValidator` without importing Node worker or filesystem code.

BEEF requests and schema-declared storage response bytes use the existing
negotiated compact binary JSON codec. Legacy peers retain numeric-array JSON
and identical transaction/proof bytes. Authentication and payload ceilings
remain unchanged; upgrade both clients and active storage for the savings.

## Backup and recovery

Recover both key material and wallet records; a manager snapshot or device
Expand Down
5 changes: 5 additions & 0 deletions docs/packages/wallet/wallet-toolbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/wall

Use this package when you are building a wallet product, a wallet-like service, or another implementation that must match BRC-100 behavior.

BEEF requests and schema-declared storage response bytes use the existing
negotiated compact binary JSON codec. Legacy peers retain numeric-array JSON
and identical transaction/proof bytes. Authentication and payload ceilings
remain unchanged; upgrade both clients and active storage for the savings.

## Optional registry descriptions

ProtoMap, BasketMap and CertMap supply descriptive metadata, not permission grants.
Expand Down
18 changes: 9 additions & 9 deletions docs/reference/package-api-migrations.md

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions governance/package-release-notes.json

Large diffs are not rendered by default.

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

## wallet-toolbox 2.14.7

- BEEF requests and schema-declared storage response bytes use the existing
negotiated compact binary JSON codec. Legacy peers retain numeric-array
JSON and identical transaction/proof bytes. Existing authentication and
payload ceilings remain unchanged; no API or database migration is required.

- BRC-177 anchors cover both the protected action and an economic reclaim.
Protected actions keep their only managed input bound to the named anchor,
create no wallet change, and pay the bounded reclaim surplus as miner fee.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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 { parseJsonRpc, stringifyJsonRpc } from '../BinaryJson'

class CapturingStorageClient extends StorageClientBase {
calls: Array<{ method: string; params: unknown[] }> = []
Expand Down Expand Up @@ -93,8 +94,119 @@ 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.each([false, true])('preserves a large BEEF request with binary negotiation %s', async binary => {
const required = makeTransaction(1000)
required.addOutput({ satoshis: 0, lockingScript: Script.fromASM(`OP_FALSE OP_RETURN ${'ff'.repeat(150_000)}`) })
const beef = new Beef()
beef.mergeTransaction(required)
const originalBytes = beef.toBinary()
const args = Validation.validateCreateActionArgs({
description: 'forward large proof data',
inputs: [{ outpoint: `${required.id('hex')}.0`, unlockingScript: '00', inputDescription: 'large input' }],
inputBEEF: originalBytes,
outputs: [{ satoshis: 1, lockingScript: '51', outputDescription: 'replacement output' }]
})
const client = new CapturingStorageClient({} as WalletInterface, 'https://storage.example.test')
await client.createAction(auth, args)
const envelope = { jsonrpc: '2.0', method: 'createAction', params: client.calls[0].params, id: 1 }
const wire = stringifyJsonRpc(envelope, binary)
const decoded = parseJsonRpc(wire, binary).params[1].inputBEEF
expect(Array.from(decoded)).toEqual(originalBytes)
expect(args.inputBEEF).toEqual(originalBytes)
if (binary) {
expect(JSON.parse(wire).params[1].inputBEEF.$bsvBinary).toBe('base64')
expect(Buffer.byteLength(wire)).toBeLessThan(Buffer.byteLength(stringifyJsonRpc(envelope, false)) / 2)
} else {
expect(JSON.parse(wire).params[1].inputBEEF).toEqual(originalBytes)
}
})

test('preserves BEEF V1 when client-side pruning is required', async () => {
Expand Down
Loading
Loading