From 503e83089b35e6147a96be87550ee835046abb71 Mon Sep 17 00:00:00 2001 From: Connor Murray Date: Wed, 19 Aug 2026 20:29:54 -0400 Subject: [PATCH 1/4] feat(sdk): implement BRC-229 multiplyPoint in ProtoWallet Reference implementation for BRC-229 (bsv-blockchain/BRCs#230). Multiplies a caller-supplied secp256k1 point by a BRC-42/43 derived private key without disclosing the key, so commutative-masking protocols -- Barnett-Smart mental poker, verifiable shuffles, oblivious transfer -- can run against a wallet-held key instead of requiring the application to generate and store secp256k1 keys of its own. In the mental-poker case those application-held keys are exactly what the privacy of a player's hand depends on. Every primitive already existed: KeyDeriver.derivePrivateKey, Point.mul, and BigNumber.invm for the invert path. The work here is validation and key discipline, not new cryptography. multiplyPoint is OPTIONAL on WalletInterface, and that is a correction to the spec rather than a convenience. Declaring it required broke 23 call sites across every substrate (WalletClient, HTTPWalletJSON, WalletWireTransceiver, window.CWI, XDM, ReactNativeWebView) plus the KV store, registry and identity clients. BRC-100 is billed as an unchanging interface, so a method added after the fact cannot be mandatory without invalidating every wallet already shipped. Applications must feature-detect and degrade. The canonical-encoding rule the spec makes normative is confirmed necessary in THIS package, not merely in theory: PublicKey.fromString accepts '02' + 'ff'*32, an x-coordinate greater than the field prime, silently reduces it to 0x1000003d0, and validate() then returns true. Verified by probe before writing the check. go-sdk has the same defect independently, which makes it an interoperability hazard rather than one library's quirk. parseValidPoint rejects the coordinate BEFORE parsing, since the parser performs the reduction. VerifiableCertificate.decryptFields is retyped from ProtoWallet to Pick. Adding any method to ProtoWallet narrows what structurally satisfies it, which broke a call passing a WalletInterface; the parameter was over-specified, as the body only ever calls decrypt(). Tests assert the properties a deal depends on rather than that the method returns a string: masks from independent wallets commute, invert recovers the original point, a three-way mask strips in an order different from the one applied, protocol/keyID/counterparty each separate keys, two wallets never derive the same protocol key, a 52-card deck masks to 52 distinct points with a selective reveal leaving the rest unreadable, and the non-canonical x-coordinate is rejected. Verified: tsc -b clean (the pre-existing TS5095 in tsconfig.cjs.json aside), oxlint --deny-warnings clean, prettier clean on both touched files, and the full sdk suite green at 157 suites / 5924 tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../certificates/VerifiableCertificate.ts | 5 +- packages/sdk/src/wallet/ProtoWallet.ts | 93 ++++++++++ packages/sdk/src/wallet/Wallet.interfaces.ts | 47 +++++ .../__tests/ProtoWallet.multiplyPoint.test.ts | 172 ++++++++++++++++++ 4 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/src/wallet/__tests/ProtoWallet.multiplyPoint.test.ts diff --git a/packages/sdk/src/auth/certificates/VerifiableCertificate.ts b/packages/sdk/src/auth/certificates/VerifiableCertificate.ts index 7b14b97d0..9bba50301 100644 --- a/packages/sdk/src/auth/certificates/VerifiableCertificate.ts +++ b/packages/sdk/src/auth/certificates/VerifiableCertificate.ts @@ -96,7 +96,10 @@ export class VerifiableCertificate extends Certificate { * @throws {Error} Throws an error if any of the decryption operations fail, with a message indicating the failure context. */ async decryptFields( - verifierWallet: ProtoWallet, + // Typed as the capability actually used rather than the whole ProtoWallet class, so + // that adding a method to ProtoWallet does not narrow what may be passed here. This + // only ever calls decrypt(). + verifierWallet: Pick, privileged?: boolean, privilegedReason?: string, originator?: OriginatorDomainNameStringUnder250Bytes diff --git a/packages/sdk/src/wallet/ProtoWallet.ts b/packages/sdk/src/wallet/ProtoWallet.ts index 15822a493..ee5c04566 100644 --- a/packages/sdk/src/wallet/ProtoWallet.ts +++ b/packages/sdk/src/wallet/ProtoWallet.ts @@ -9,6 +9,7 @@ import { PublicKey, Point, PrivateKey, + Curve, SymmetricKey, readyAsyncCryptoBackend, isAsyncCryptoDigest, @@ -20,6 +21,8 @@ import { CreateSignatureArgs, CreateSignatureResult, GetPublicKeyArgs, + MultiplyPointArgs, + MultiplyPointResult, PubKeyHex, RevealCounterpartyKeyLinkageArgs, RevealCounterpartyKeyLinkageResult, @@ -88,6 +91,51 @@ async function deriveSymmetricKey( return keyDeriver.deriveSymmetricKey(protocolID, keyID, counterparty) } +/** + * Parses a compressed DER point, rejecting everything a conforming BRC-229 + * implementation must reject. + * + * The canonical-encoding check is NOT redundant with the on-curve check, and omitting it is + * the likely way to build a non-conforming implementation. PublicKey.fromString accepts an + * x-coordinate numerically greater than the field prime, reduces it modulo p without + * signalling anything, and validate() then reports the reduced point as on-curve. Verified + * against this package: '02' + 'ff'*32 parses, reduces to 0x1000003d0, and validates true. + * Accepting it would admit a point that was never validly encoded, which is the entry point + * for invalid-curve attacks. + */ +function parseValidPoint(pointHex: PubKeyHex): Point { + if (typeof pointHex !== 'string' || !/^0[23][0-9a-fA-F]{64}$/.test(pointHex)) { + throw new Error('multiplyPoint: point must be a 33-byte compressed DER secp256k1 point in hex') + } + + const curve = new Curve() + // Reject before parsing, because the parser is what silently reduces the coordinate. + if (new BigNumber(pointHex.slice(2), 16).cmp(curve.p) >= 0) { + throw new Error('multiplyPoint: the x-coordinate is not a canonical field element') + } + + let point: Point + try { + point = Point.fromString(pointHex) + } catch { + throw new Error('multiplyPoint: the supplied point could not be decoded') + } + + if (point.isInfinity()) { + throw new Error('multiplyPoint: the supplied point is the identity') + } + // Guard y as well. The compressed form derives y, but a decoder that produced a + // non-canonical y would otherwise reach the curve check unexamined. + const y = point.getY() + if (y.cmp(curve.p) >= 0 || y.isNeg()) { + throw new Error('multiplyPoint: the y-coordinate is not a canonical field element') + } + if (!point.validate()) { + throw new Error('multiplyPoint: the supplied point is not on the curve') + } + return point +} + /** * A ProtoWallet is precursor to a full wallet, capable of performing all foundational cryptographic operations. * It can derive keys, create signatures, facilitate encryption and HMAC operations, and reveal key linkages. @@ -130,6 +178,51 @@ export class ProtoWallet { } } + /** + * Multiplies a caller-supplied secp256k1 point by a BRC-42/43 derived private key, + * returning the resulting point. The private key is never revealed. + * + * This is the BRC-229 primitive. It exists so commutative-masking protocols + * (Barnett-Smart mental poker, verifiable shuffles, oblivious transfer) can run against a + * wallet-held key instead of requiring the application to generate and store secp256k1 + * keys of its own. + * + * The key is always derived from protocolID/keyID/counterparty and is never the identity + * key or a spending key. That restriction is load-bearing rather than stylistic: for a + * counterparty point Q, d*Q IS the ECDH shared secret with Q, so performing this operation + * with a key used for anything else would hand the caller that secret and break encryption + * to that counterparty. A protocol-scoped key that never signs and never encrypts has no + * such property to lose. + */ + async multiplyPoint(args: MultiplyPointArgs): Promise { + if (args.protocolID == null || args.keyID == null || args.keyID === '') { + throw new Error('protocolID and keyID are required.') + } + const keyDeriver = keyDeriverOrThrow(this.keyDeriver) + const point = parseValidPoint(args.point) + const derived = derivePrivateKey( + keyDeriver, + args.protocolID, + args.keyID, + args.counterparty ?? 'self' + ) + + const curve = new Curve() + // With invert, multiply by d^-1 mod n rather than d, so a mask applied under this + // derivation can be stripped by the same wallet. + const scalar = args.invert === true ? derived.invm(curve.n) : new BigNumber(derived.toHex(), 16) + + const result = point.mul(scalar) + + // Refuse a result at infinity rather than encoding it: it carries no information, and + // every protocol built on this primitive treats it as a failure. + if (result.isInfinity()) { + throw new Error('multiplyPoint: the result is the point at infinity') + } + + return { point: result.encode(true, 'hex') as string } + } + async revealCounterpartyKeyLinkage( args: RevealCounterpartyKeyLinkageArgs ): Promise { diff --git a/packages/sdk/src/wallet/Wallet.interfaces.ts b/packages/sdk/src/wallet/Wallet.interfaces.ts index 8d40c90ee..dccfa030c 100644 --- a/packages/sdk/src/wallet/Wallet.interfaces.ts +++ b/packages/sdk/src/wallet/Wallet.interfaces.ts @@ -669,6 +669,35 @@ export interface WalletEncryptionArgs { * @param {BooleanDefaultFalse|true} [identityKey] - Use true to retrieve the current user's own identity key, overriding any protocol ID, key ID, or counterparty specified. * @param {BooleanDefaultFalse} [forSelf] - Whether to return the public key derived from the current user's own identity (as opposed to the counterparty's identity). */ +/** + * Arguments for BRC-229 wallet-native elliptic curve point multiplication. + */ +export interface MultiplyPointArgs { + /** The point to multiply, as a compressed DER-encoded secp256k1 point. */ + point: PubKeyHex + /** BRC-43 security level and protocol ID used to derive the key. */ + protocolID: WalletProtocol + /** BRC-43 key ID used to derive the key. */ + keyID: KeyIDStringUnder800Bytes + /** Counterparty for derivation. Defaults to 'self'. */ + counterparty?: WalletCounterparty + /** + * Multiply by the modular inverse of the derived key instead of the key itself, + * so a mask applied by the wallet can be removed by the wallet. + */ + invert?: BooleanDefaultFalse + privileged?: BooleanDefaultFalse + privilegedReason?: DescriptionString5to50Bytes + seekPermission?: BooleanDefaultTrue +} + +/** + * Result of a BRC-229 point multiplication: the resulting point, compressed DER-encoded. + */ +export interface MultiplyPointResult { + point: PubKeyHex +} + export interface GetPublicKeyArgs extends Partial { identityKey?: true forSelf?: BooleanDefaultFalse @@ -1025,6 +1054,24 @@ export interface WalletInterface { originator?: OriginatorDomainNameStringUnder250Bytes ) => Promise + /** + * Multiplies a caller-supplied secp256k1 point by a derived private key, returning the + * resulting point without revealing the key (BRC-229). Set `invert` to multiply by the + * modular inverse instead, undoing a mask previously applied under the same derivation. + * + * Optional. BRC-100 is an unchanging interface, so a method added after the fact cannot be + * mandatory without invalidating every wallet and substrate already shipped. Applications + * MUST feature-detect (`typeof wallet.multiplyPoint === 'function'`) and degrade gracefully. + * + * @param {MultiplyPointArgs} args - The point, the BRC-43 derivation arguments, and options. + * @param {OriginatorDomainNameStringUnder250Bytes} [originator] - FQDN of the originating application. + * @returns {Promise} Resolves to the resulting point, or an error response. + */ + multiplyPoint?: ( + args: MultiplyPointArgs, + originator?: OriginatorDomainNameStringUnder250Bytes + ) => Promise + /** * Reveals the key linkage between ourselves and a counterparty, to a particular verifier, across all interactions with the counterparty. * diff --git a/packages/sdk/src/wallet/__tests/ProtoWallet.multiplyPoint.test.ts b/packages/sdk/src/wallet/__tests/ProtoWallet.multiplyPoint.test.ts new file mode 100644 index 000000000..7fd3455b9 --- /dev/null +++ b/packages/sdk/src/wallet/__tests/ProtoWallet.multiplyPoint.test.ts @@ -0,0 +1,172 @@ +import ProtoWallet from '../../wallet/ProtoWallet' +import { PrivateKey, Curve, BigNumber } from '../../primitives/index' + +/** + * BRC-229: wallet-native elliptic curve point multiplication. + * + * These tests assert the properties a commutative-masking protocol actually depends on, + * rather than that the method returns some string. If any of them fail, mental poker built + * on this primitive is broken. + */ + +const PROTOCOL: [0 | 1 | 2, string] = [2, 'mental poker deal'] +const OTHER_PROTOCOL: [0 | 1 | 2, string] = [2, 'a different scheme'] + +/** Card i is encoded as (i+1)*G, the standard Barnett-Smart card encoding. */ +const card = (i: number): string => { + const curve = new Curve() + return curve.g.mul(new BigNumber(i + 1)).encode(true, 'hex') as string +} + +let alice: ProtoWallet +let bob: ProtoWallet +let carol: ProtoWallet + +beforeEach(() => { + alice = new ProtoWallet(PrivateKey.fromRandom()) + bob = new ProtoWallet(PrivateKey.fromRandom()) + carol = new ProtoWallet(PrivateKey.fromRandom()) +}) + +describe('ProtoWallet.multiplyPoint (BRC-229)', () => { + it('masks commute across independent wallets', async () => { + // The property the whole construction rests on: a*(b*P) == b*(a*P), so players may + // apply their masks in any order and still agree on the deck. + const P = card(0) + + const ab = await bob.multiplyPoint({ + point: (await alice.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' })).point, + protocolID: PROTOCOL, + keyID: '1' + }) + const ba = await alice.multiplyPoint({ + point: (await bob.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' })).point, + protocolID: PROTOCOL, + keyID: '1' + }) + + expect(ab.point).toEqual(ba.point) + }) + + it('invert recovers the original point', async () => { + const P = card(7) + const masked = await alice.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' }) + expect(masked.point).not.toEqual(P) + + const unmasked = await alice.multiplyPoint({ + point: masked.point, + protocolID: PROTOCOL, + keyID: '1', + invert: true + }) + expect(unmasked.point).toEqual(P) + }) + + it('a three-way mask strips in any order', async () => { + // No dealer exists, so unmasking order is whatever the table happens to do. + const P = card(12) + let deck = P + for (const w of [alice, bob, carol]) { + deck = (await w.multiplyPoint({ point: deck, protocolID: PROTOCOL, keyID: 'k' })).point + } + expect(deck).not.toEqual(P) + + // Strip in a deliberately different order from the one used to apply. + for (const w of [bob, carol, alice]) { + deck = ( + await w.multiplyPoint({ point: deck, protocolID: PROTOCOL, keyID: 'k', invert: true }) + ).point + } + expect(deck).toEqual(P) + }) + + it('separates keys by protocol, key ID and counterparty', async () => { + const P = card(3) + const base = await alice.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' }) + const otherProto = await alice.multiplyPoint({ + point: P, + protocolID: OTHER_PROTOCOL, + keyID: '1' + }) + const otherKey = await alice.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '2' }) + const otherParty = await alice.multiplyPoint({ + point: P, + protocolID: PROTOCOL, + keyID: '1', + counterparty: (await bob.getPublicKey({ identityKey: true })).publicKey + }) + + const all = [base.point, otherProto.point, otherKey.point, otherParty.point] + expect(new Set(all).size).toEqual(all.length) + }) + + it('two wallets never derive the same protocol key', async () => { + const P = card(5) + const a = await alice.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' }) + const b = await bob.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' }) + expect(a.point).not.toEqual(b.point) + }) + + it('masks a whole deck and reveals one position without leaking the others', async () => { + const deck = Array.from({ length: 52 }, (_, i) => card(i)) + const masked = await Promise.all( + deck.map( + async (p, i) => + (await alice.multiplyPoint({ point: p, protocolID: PROTOCOL, keyID: String(i) })).point + ) + ) + + // Every masked position differs from its plaintext and from every other position. + expect(new Set(masked).size).toEqual(52) + masked.forEach((m, i) => expect(m).not.toEqual(deck[i])) + + // Revealing position 17 discloses that card and nothing else. + const revealed = await alice.multiplyPoint({ + point: masked[17], + protocolID: PROTOCOL, + keyID: '17', + invert: true + }) + expect(revealed.point).toEqual(deck[17]) + for (let i = 0; i < 52; i++) { + if (i !== 17) expect(masked[i]).not.toEqual(deck[i]) + } + }) + + it('rejects a non-canonical x-coordinate that on-curve checks alone accept', async () => { + // The regression that motivates the spec's canonical-encoding rule. This x is greater + // than the field prime; PublicKey.fromString reduces it silently and validate() then + // returns true, so an implementation checking only the curve equation accepts a point + // that was never validly encoded. + const nonCanonical = '02' + 'ff'.repeat(32) + const curve = new Curve() + expect(new BigNumber('ff'.repeat(32), 16).cmp(curve.p)).toBeGreaterThanOrEqual(0) + + await expect( + alice.multiplyPoint({ point: nonCanonical, protocolID: PROTOCOL, keyID: '1' }) + ).rejects.toThrow(/canonical field element/) + }) + + it('rejects malformed, off-curve and identity points', async () => { + const bad: Record = { + empty: '', + 'not hex': '02' + 'zz'.repeat(32), + 'too short': '02ab', + 'bad prefix': '04' + 'ab'.repeat(32), + 'all zeros': '02' + '00'.repeat(32) + } + for (const [name, point] of Object.entries(bad)) { + await expect( + alice.multiplyPoint({ point, protocolID: PROTOCOL, keyID: '1' }) + ).rejects.toThrow() + expect(name).toBeTruthy() + } + }) + + it('requires protocolID and keyID', async () => { + const P = card(0) + await expect( + alice.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '' }) + ).rejects.toThrow(/required/) + }) +}) From 4ce9e4b5615c3d3d059cd075c54d76f829551799 Mon Sep 17 00:00:00 2001 From: Connor Murray Date: Wed, 19 Aug 2026 20:41:50 -0400 Subject: [PATCH 2/4] feat(sdk): carry BRC-229 multiplyPoint across the wire substrates Without this the primitive works in-process only, which excludes every real wallet: BSV Desktop and anything else an application reaches is on the far side of a substrate. This is the half that makes the method usable from a browser. Call code 29, taking the next free slot after getVersion = 28, matching the BRC-100 call code table. Frame layout: the point travels as its 33 raw bytes rather than as hex, matching how getPublicKey returns a key, followed by the existing key-related parameter block (protocolID, keyID, counterparty, privileged, privilegedReason) and then invert and seekPermission as optional booleans. invert is written with the optional-boolean encoding rather than a bare flag specifically so a wallet reading a frame that lacks it cannot mistake absence for true -- silently inverting a mask would corrupt a deal rather than fail it. Because multiplyPoint is optional on the interface, the processor checks for the method before dispatching and returns a legible wire error instead of calling undefined. WalletClient does the same and additionally exposes supportsMultiplyPoint(), so an application can feature-detect and choose a fallback before committing to a protocol that needs the primitive. That is the concrete form of the feature detection BRC-229 requires. Coverage across InvokableWalletBase (window.CWI, XDM, ReactNativeWebView), HTTPWalletJSON, WalletWireTransceiver/Processor and WalletClient. Seven wire tests. The load-bearing ones: a mask applied through the wire strips through the wire, and the wire result equals the in-process result for the same key and point, so the encoding demonstrably loses nothing. Also covered: call code 29 is where the table says, masks commute across two wallets each reached over a substrate, counterparty and invert survive the frame independently, the non-canonical x-coordinate is still rejected after the round trip, and a wallet lacking the method produces a clear error. Verified: tsc -b clean (pre-existing TS5095 aside), oxlint --deny-warnings clean, prettier clean on the files this commit newly touches -- WalletWireCalls, InvokableWalletBase and WalletClient were already unformatted on a clean tree and are deliberately left that way rather than reformatted here. Full sdk suite green at 158 suites / 5931 tests. Co-Authored-By: Claude Opus 5 (1M context) --- packages/sdk/src/wallet/WalletClient.ts | 27 +++- .../src/wallet/substrates/HTTPWalletJSON.ts | 8 +- .../wallet/substrates/InvokableWalletBase.ts | 8 +- .../src/wallet/substrates/WalletWireCalls.ts | 3 + .../wallet/substrates/WalletWireProcessor.ts | 28 ++++ .../substrates/WalletWireTransceiver.ts | 33 ++++- .../__tests/multiplyPoint.wire.test.ts | 125 ++++++++++++++++++ 7 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 packages/sdk/src/wallet/substrates/__tests/multiplyPoint.wire.test.ts diff --git a/packages/sdk/src/wallet/WalletClient.ts b/packages/sdk/src/wallet/WalletClient.ts index f6db0ca03..15bad3b9e 100644 --- a/packages/sdk/src/wallet/WalletClient.ts +++ b/packages/sdk/src/wallet/WalletClient.ts @@ -33,7 +33,9 @@ import { SignActionResult, VersionString7To30Bytes, WalletInterface, - AuthenticatedResult + AuthenticatedResult, + MultiplyPointArgs, + MultiplyPointResult } from './Wallet.interfaces.js' import WindowCWISubstrate from './substrates/window.CWI.js' import XDMSubstrate from './substrates/XDM.js' @@ -234,6 +236,29 @@ export default class WalletClient implements WalletInterface { return await (this.substrate as WalletInterface).getPublicKey(args, this.originator) } + /** + * BRC-229 point multiplication. Optional across the interface, so this throws a clear error + * when the connected substrate does not implement it rather than failing on `undefined`. + * Applications should feature-detect with {@link WalletClient.supportsMultiplyPoint}. + */ + async multiplyPoint(args: MultiplyPointArgs): Promise { + await this.connectToSubstrate() + const substrate = this.substrate as WalletInterface + if (typeof substrate.multiplyPoint !== 'function') { + throw new Error('The connected wallet does not implement multiplyPoint (BRC-229)') + } + return await substrate.multiplyPoint(args, this.originator) + } + + /** + * Reports whether the connected wallet implements BRC-229 point multiplication, so an + * application can choose a fallback before committing to a protocol that needs it. + */ + async supportsMultiplyPoint(): Promise { + await this.connectToSubstrate() + return typeof (this.substrate as WalletInterface).multiplyPoint === 'function' + } + async revealCounterpartyKeyLinkage(args: { counterparty: PubKeyHex verifier: PubKeyHex diff --git a/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts b/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts index ff5d138b8..eedf7e37e 100644 --- a/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts +++ b/packages/sdk/src/wallet/substrates/HTTPWalletJSON.ts @@ -33,7 +33,9 @@ import { SecurityLevel, SignActionArgs, SignActionResult, - VersionString7To30Bytes + VersionString7To30Bytes, + MultiplyPointArgs, + MultiplyPointResult } from '../Wallet.interfaces.js' import { WERR_REVIEW_ACTIONS } from '../WERR_REVIEW_ACTIONS.js' import { WERR_INVALID_PARAMETER } from '../WERR_INVALID_PARAMETER.js' @@ -168,6 +170,10 @@ export default class HTTPWalletJSON implements WalletInterface { return (await this.api('getPublicKey', args)) as { publicKey: PubKeyHex } } + async multiplyPoint(args: MultiplyPointArgs): Promise { + return (await this.api('multiplyPoint', args)) as MultiplyPointResult + } + async revealCounterpartyKeyLinkage(args: { counterparty: PubKeyHex verifier: PubKeyHex diff --git a/packages/sdk/src/wallet/substrates/InvokableWalletBase.ts b/packages/sdk/src/wallet/substrates/InvokableWalletBase.ts index 83accc0eb..c752baeba 100644 --- a/packages/sdk/src/wallet/substrates/InvokableWalletBase.ts +++ b/packages/sdk/src/wallet/substrates/InvokableWalletBase.ts @@ -48,7 +48,9 @@ import { GetHeaderArgs, GetHeaderResult, GetNetworkResult, - GetVersionResult + GetVersionResult, + MultiplyPointArgs, + MultiplyPointResult } from '../Wallet.interfaces.js' import { CallType } from './WalletWireCalls.js' @@ -94,6 +96,10 @@ export abstract class InvokableWalletBase implements WalletInterface { return await this.invoke('getPublicKey', args) } + async multiplyPoint(args: MultiplyPointArgs): Promise { + return await this.invoke('multiplyPoint', args) + } + async revealCounterpartyKeyLinkage(args: RevealCounterpartyKeyLinkageArgs): Promise { return await this.invoke('revealCounterpartyKeyLinkage', args) } diff --git a/packages/sdk/src/wallet/substrates/WalletWireCalls.ts b/packages/sdk/src/wallet/substrates/WalletWireCalls.ts index e57fed283..14b4f1cc3 100644 --- a/packages/sdk/src/wallet/substrates/WalletWireCalls.ts +++ b/packages/sdk/src/wallet/substrates/WalletWireCalls.ts @@ -28,6 +28,9 @@ enum calls { getHeaderForHeight = 26, getNetwork = 27, getVersion = 28, + // BRC-229. Optional: a wallet that does not implement it returns an error over the wire, + // which is how a caller feature-detects across a substrate. + multiplyPoint = 29, } export default calls diff --git a/packages/sdk/src/wallet/substrates/WalletWireProcessor.ts b/packages/sdk/src/wallet/substrates/WalletWireProcessor.ts index 61eb9bbd0..8d94c0948 100644 --- a/packages/sdk/src/wallet/substrates/WalletWireProcessor.ts +++ b/packages/sdk/src/wallet/substrates/WalletWireProcessor.ts @@ -1165,6 +1165,34 @@ export default class WalletWireProcessor implements WalletWire { return responseWriter.toUint8Array() })() + case 'multiplyPoint': + return await (async () => { + // BRC-229. The point is a fixed 33 bytes, so it is read positionally before the + // variable-length key parameters. + const args: any = {} + args.point = Utils.toHex(paramsReader.read(33)) + Object.assign(args, this.decodeKeyRelatedParams(paramsReader)) + + const invertFlag = paramsReader.readInt8() + args.invert = invertFlag === -1 ? undefined : invertFlag === 1 + + const seekPermission = paramsReader.readInt8() + args.seekPermission = seekPermission === -1 ? undefined : seekPermission === 1 + + // multiplyPoint is optional on the interface, so a wallet may legitimately not + // implement it. Report that as an error rather than calling undefined, which is + // how a caller feature-detects across a substrate. + if (typeof this.wallet.multiplyPoint !== 'function') { + throw new Error('multiplyPoint is not implemented by this wallet') + } + const multiplyPointResult = await this.wallet.multiplyPoint(args, originator) + + const responseWriter = new Utils.WriterUint8Array() + responseWriter.writeUInt8(0) // errorByte = 0 + responseWriter.write(Utils.toUint8Array(multiplyPointResult.point, 'hex')) + return responseWriter.toUint8Array() + })() + case 'encrypt': return await (async () => { const args: any = this.decodeKeyRelatedParams(paramsReader) diff --git a/packages/sdk/src/wallet/substrates/WalletWireTransceiver.ts b/packages/sdk/src/wallet/substrates/WalletWireTransceiver.ts index 95c77c46a..a437897c0 100644 --- a/packages/sdk/src/wallet/substrates/WalletWireTransceiver.ts +++ b/packages/sdk/src/wallet/substrates/WalletWireTransceiver.ts @@ -44,7 +44,9 @@ import { VersionString7To30Bytes, WalletInterface, ActionStatus, - SendWithResultStatus + SendWithResultStatus, + MultiplyPointArgs, + MultiplyPointResult } from '../Wallet.interfaces.js' import WalletWire from './WalletWire.js' import Certificate from '../../auth/certificates/Certificate.js' @@ -619,6 +621,35 @@ export default class WalletWireTransceiver implements WalletInterface { } } + /** + * BRC-229 point multiplication over the wire. + * + * The point travels as its 33 raw bytes rather than as hex, matching how getPublicKey + * returns a key. `invert` rides as an optional boolean so that a wallet reading an older + * frame layout cannot mistake its absence for `true`. + */ + async multiplyPoint( + args: MultiplyPointArgs, + originator?: OriginatorDomainNameStringUnder250Bytes + ): Promise { + const paramWriter = new Utils.WriterUint8Array() + paramWriter.write(Utils.toUint8Array(args.point, 'hex')) + paramWriter.write( + this.encodeKeyRelatedParams( + args.protocolID, + args.keyID, + args.counterparty, + args.privileged, + args.privilegedReason + ) + ) + this.writeOptionalBool(paramWriter, args.invert) + this.writeOptionalBool(paramWriter, args.seekPermission) + + const result = await this.transmit('multiplyPoint', originator, paramWriter.toUint8Array()) + return { point: Utils.toHex(result) } + } + async revealCounterpartyKeyLinkage( args: { counterparty: PubKeyHex diff --git a/packages/sdk/src/wallet/substrates/__tests/multiplyPoint.wire.test.ts b/packages/sdk/src/wallet/substrates/__tests/multiplyPoint.wire.test.ts new file mode 100644 index 000000000..60fbd4570 --- /dev/null +++ b/packages/sdk/src/wallet/substrates/__tests/multiplyPoint.wire.test.ts @@ -0,0 +1,125 @@ +import ProtoWallet from '../../../wallet/ProtoWallet' +import WalletWireTransceiver from '../../../wallet/substrates/WalletWireTransceiver' +import WalletWireProcessor from '../../../wallet/substrates/WalletWireProcessor' +import calls from '../../../wallet/substrates/WalletWireCalls' +import { PrivateKey, Curve, BigNumber } from '../../../primitives/index' +import type { WalletInterface } from '../../../wallet/Wallet.interfaces' + +/** + * BRC-229 over the serialized wire substrate. + * + * ProtoWallet already has unit coverage for the maths. What these tests establish is that the + * frame encoding preserves it: a mask applied through the wire must be strippable through the + * wire, or a wallet reached over a substrate (which is every real wallet, including BSV + * Desktop) cannot participate in the protocol. + */ + +const PROTOCOL: [0 | 1 | 2, string] = [2, 'mental poker deal'] + +const card = (i: number): string => + new Curve().g.mul(new BigNumber(i + 1)).encode(true, 'hex') as string + +const wireWallet = (underlying: ProtoWallet): WalletWireTransceiver => + new WalletWireTransceiver(new WalletWireProcessor(underlying as unknown as WalletInterface)) + +describe('multiplyPoint over the wire substrate (BRC-229)', () => { + it('is assigned call code 29', () => { + // The BRC-100 call code table ends at 28 (getVersion), so 29 is the next free code. + expect(calls.multiplyPoint).toEqual(29) + expect(calls.getVersion).toEqual(28) + }) + + it('round-trips a masked point and strips it again', async () => { + const wallet = wireWallet(new ProtoWallet(PrivateKey.fromRandom())) + const P = card(0) + + const masked = await wallet.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' }) + expect(masked.point).not.toEqual(P) + expect(masked.point).toMatch(/^0[23][0-9a-f]{64}$/) + + const unmasked = await wallet.multiplyPoint({ + point: masked.point, + protocolID: PROTOCOL, + keyID: '1', + invert: true + }) + expect(unmasked.point).toEqual(P) + }) + + it('agrees with the in-process result, so the encoding loses nothing', async () => { + const key = PrivateKey.fromRandom() + const direct = new ProtoWallet(key) + const overWire = wireWallet(new ProtoWallet(key)) + const P = card(9) + + const a = await direct.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: 'k' }) + const b = await overWire.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: 'k' }) + expect(b.point).toEqual(a.point) + }) + + it('commutes across two wallets reached over the wire', async () => { + const alice = wireWallet(new ProtoWallet(PrivateKey.fromRandom())) + const bob = wireWallet(new ProtoWallet(PrivateKey.fromRandom())) + const P = card(4) + + const ab = await bob.multiplyPoint({ + point: (await alice.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' })).point, + protocolID: PROTOCOL, + keyID: '1' + }) + const ba = await alice.multiplyPoint({ + point: (await bob.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' })).point, + protocolID: PROTOCOL, + keyID: '1' + }) + expect(ab.point).toEqual(ba.point) + }) + + it('carries counterparty and invert through the frame independently', async () => { + const alice = new ProtoWallet(PrivateKey.fromRandom()) + const bob = new ProtoWallet(PrivateKey.fromRandom()) + const wired = wireWallet(alice) + const bobKey = (await bob.getPublicKey({ identityKey: true })).publicKey + const P = card(2) + + // A counterparty-scoped mask must differ from a self-scoped one and still invert. + const selfMask = await wired.multiplyPoint({ point: P, protocolID: PROTOCOL, keyID: '1' }) + const partyMask = await wired.multiplyPoint({ + point: P, + protocolID: PROTOCOL, + keyID: '1', + counterparty: bobKey + }) + expect(partyMask.point).not.toEqual(selfMask.point) + + const back = await wired.multiplyPoint({ + point: partyMask.point, + protocolID: PROTOCOL, + keyID: '1', + counterparty: bobKey, + invert: true + }) + expect(back.point).toEqual(P) + }) + + it('propagates a rejected point as a wire error rather than a bad point', async () => { + const wallet = wireWallet(new ProtoWallet(PrivateKey.fromRandom())) + // The non-canonical x-coordinate: greater than the field prime, silently reduced by the + // parser, and reported on-curve. It must not survive the trip. + await expect( + wallet.multiplyPoint({ point: '02' + 'ff'.repeat(32), protocolID: PROTOCOL, keyID: '1' }) + ).rejects.toThrow(/canonical field element/) + }) + + it('reports a clear error when the wallet does not implement the method', async () => { + // multiplyPoint is optional, so a substrate may front a wallet without it. The caller must + // get a legible error, not a crash on undefined. + const withoutMethod = { getVersion: async () => ({ version: '1.0.0' }) } + const wallet = new WalletWireTransceiver( + new WalletWireProcessor(withoutMethod as unknown as WalletInterface) + ) + await expect( + wallet.multiplyPoint({ point: card(0), protocolID: PROTOCOL, keyID: '1' }) + ).rejects.toThrow(/not implemented/) + }) +}) From 3a9d13a8249bcff69f45e5b74e8aeff5618bac12 Mon Sep 17 00:00:00 2001 From: Connor Murray Date: Wed, 19 Aug 2026 20:47:12 -0400 Subject: [PATCH 3/4] fix(sdk): keep ProtoWallet.multiplyPoint structurally optional CI failure, and my own inconsistency: multiplyPoint was declared optional on WalletInterface but required on the ProtoWallet class. A required member -- method or property, an arrow property is no different -- narrows what structurally satisfies ProtoWallet, so every implementor that does not extend the class stops type-checking. That broke @bsv/wallet-toolbox with 6 compile errors: Wallet and PrivilegedKeyManager 'incorrectly implements class ProtoWallet', three 'Argument of type this is not assignable to parameter of type ProtoWallet', and proveCertificate reporting multiplyPoint missing from Wallet. Confirmed against a baseline build of main's SDK, which leaves wallet-toolbox at its 4 pre-existing TS2307 errors for unrelated missing express middleware packages -- so all 6 were mine. Declaring the member with and assigning the implementation keeps ProtoWallet as wide a structural type as it was before BRC-229 existed, while the class still provides the method. This is the same conclusion the interface change already reached, applied consistently: an opt-in capability must be opt-in on every type that carries it, or it is not opt-in at all. Adds a regression test asserting the structural shape rather than the behaviour, since the behaviour tests all passed while the type was wrong. It pins that an object with no multiplyPoint still satisfies Pick. Verified: wallet-toolbox back to its 4 baseline errors with 0 attributable to this branch, sdk tsc -b clean, oxlint clean repo-wide, prettier clean on every file this branch touches, full sdk suite green at 158 suites / 5932 tests. Co-Authored-By: Claude Opus 5 (1M context) --- packages/sdk/src/wallet/ProtoWallet.ts | 10 +++++++++- .../__tests/ProtoWallet.multiplyPoint.test.ts | 13 +++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/wallet/ProtoWallet.ts b/packages/sdk/src/wallet/ProtoWallet.ts index ee5c04566..a4bd63fd1 100644 --- a/packages/sdk/src/wallet/ProtoWallet.ts +++ b/packages/sdk/src/wallet/ProtoWallet.ts @@ -194,7 +194,15 @@ export class ProtoWallet { * to that counterparty. A protocol-scoped key that never signs and never encrypts has no * such property to lose. */ - async multiplyPoint(args: MultiplyPointArgs): Promise { + // Declared optional (`?`) so that ProtoWallet remains as wide a structural type as it was + // before BRC-229 existed. A required member -- method or property -- narrows what satisfies + // `ProtoWallet`, and every implementor that does not extend the class stops type-checking: + // Wallet, PrivilegedKeyManager and the wallet managers in @bsv/wallet-toolbox all implement + // it structurally. BRC-229 is an opt-in capability, so its presence on the type is opt-in + // too, and callers feature-detect exactly as they do on WalletInterface. + multiplyPoint?: (args: MultiplyPointArgs) => Promise = async ( + args: MultiplyPointArgs + ): Promise => { if (args.protocolID == null || args.keyID == null || args.keyID === '') { throw new Error('protocolID and keyID are required.') } diff --git a/packages/sdk/src/wallet/__tests/ProtoWallet.multiplyPoint.test.ts b/packages/sdk/src/wallet/__tests/ProtoWallet.multiplyPoint.test.ts index 7fd3455b9..e985ceb00 100644 --- a/packages/sdk/src/wallet/__tests/ProtoWallet.multiplyPoint.test.ts +++ b/packages/sdk/src/wallet/__tests/ProtoWallet.multiplyPoint.test.ts @@ -163,6 +163,19 @@ describe('ProtoWallet.multiplyPoint (BRC-229)', () => { } }) + it('stays structurally optional so existing implementors still satisfy ProtoWallet', () => { + // Regression guard. Declaring multiplyPoint as a required member -- method or property -- + // narrows what structurally satisfies ProtoWallet, and every implementor that does not + // extend the class stops type-checking. That broke Wallet, PrivilegedKeyManager and the + // wallet managers in @bsv/wallet-toolbox with 6 compile errors. This asserts the shape a + // structural implementor needs: no multiplyPoint, and it still assigns. + const withoutMultiplyPoint: Pick = {} + expect(withoutMultiplyPoint.multiplyPoint).toBeUndefined() + + // And the real wallet does provide it. + expect(typeof alice.multiplyPoint).toEqual('function') + }) + it('requires protocolID and keyID', async () => { const P = card(0) await expect( From 7d6b7b760b36d71b1f98711bcc606a5367c19554 Mon Sep 17 00:00:00 2001 From: Connor Murray Date: Wed, 19 Aug 2026 20:52:30 -0400 Subject: [PATCH 4/4] fix(sdk): raise TypeError for BRC-229 capability checks Clears the two SonarCloud findings that failed the zero-new-findings gate on PR #487 (typescript:S7786, WalletClient.ts:248 and WalletWireProcessor.ts:1186): 'new Error() is too unspecific for a type check. Use new TypeError() instead.' Sonar is right on the substance rather than merely on style. Both sites test typeof wallet.multiplyPoint !== 'function' -- a failed type check, which is exactly what TypeError denotes. A caller feature-detecting BRC-229 support can now distinguish 'this wallet lacks the capability' from a protocol or validation failure by error type instead of by parsing a message. Also splits the parseValidPoint guard, which conflated two different faults in one condition. A non-string argument is a type error; a string in the wrong format is not. They now raise TypeError and Error respectively. Sonar did not flag this one, but it is the same class of defect and would likely surface once the rule is applied to the file again. Verified: sdk tsc -b clean, oxlint clean repo-wide, prettier clean on every touched file, full sdk suite green at 158 suites / 5932 tests, and wallet-toolbox still at its 4 pre-existing TS2307 baseline with 0 attributable to this branch. Co-Authored-By: Claude Opus 5 (1M context) --- packages/sdk/src/wallet/ProtoWallet.ts | 7 ++++++- packages/sdk/src/wallet/WalletClient.ts | 7 ++----- packages/sdk/src/wallet/substrates/WalletWireProcessor.ts | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/sdk/src/wallet/ProtoWallet.ts b/packages/sdk/src/wallet/ProtoWallet.ts index a4bd63fd1..cf9c0d4d3 100644 --- a/packages/sdk/src/wallet/ProtoWallet.ts +++ b/packages/sdk/src/wallet/ProtoWallet.ts @@ -104,7 +104,12 @@ async function deriveSymmetricKey( * for invalid-curve attacks. */ function parseValidPoint(pointHex: PubKeyHex): Point { - if (typeof pointHex !== 'string' || !/^0[23][0-9a-fA-F]{64}$/.test(pointHex)) { + // A wrong type and a wrong format are different faults, so they raise different errors: + // TypeError for the former (which is also what Sonar's S7786 asks for), Error for the latter. + if (typeof pointHex !== 'string') { + throw new TypeError('multiplyPoint: point must be a string') + } + if (!/^0[23][0-9a-fA-F]{64}$/.test(pointHex)) { throw new Error('multiplyPoint: point must be a 33-byte compressed DER secp256k1 point in hex') } diff --git a/packages/sdk/src/wallet/WalletClient.ts b/packages/sdk/src/wallet/WalletClient.ts index 15bad3b9e..89b95956b 100644 --- a/packages/sdk/src/wallet/WalletClient.ts +++ b/packages/sdk/src/wallet/WalletClient.ts @@ -144,10 +144,7 @@ export default class WalletClient implements WalletInterface { () => new HTTPWalletJSON(this.originator, 'https://localhost:2121'), MAX_FAST_SUBSTRATE_RESPONSE_WAIT ), - attemptSubstrate( - () => new HTTPWalletJSON(this.originator), - MAX_FAST_SUBSTRATE_RESPONSE_WAIT - ), + attemptSubstrate(() => new HTTPWalletJSON(this.originator), MAX_FAST_SUBSTRATE_RESPONSE_WAIT), attemptSubstrate( () => new ReactNativeWebView('*', MAX_FAST_SUBSTRATE_RESPONSE_WAIT), MAX_FAST_SUBSTRATE_RESPONSE_WAIT @@ -245,7 +242,7 @@ export default class WalletClient implements WalletInterface { await this.connectToSubstrate() const substrate = this.substrate as WalletInterface if (typeof substrate.multiplyPoint !== 'function') { - throw new Error('The connected wallet does not implement multiplyPoint (BRC-229)') + throw new TypeError('The connected wallet does not implement multiplyPoint (BRC-229)') } return await substrate.multiplyPoint(args, this.originator) } diff --git a/packages/sdk/src/wallet/substrates/WalletWireProcessor.ts b/packages/sdk/src/wallet/substrates/WalletWireProcessor.ts index 8d94c0948..8b7882cf4 100644 --- a/packages/sdk/src/wallet/substrates/WalletWireProcessor.ts +++ b/packages/sdk/src/wallet/substrates/WalletWireProcessor.ts @@ -1183,7 +1183,7 @@ export default class WalletWireProcessor implements WalletWire { // implement it. Report that as an error rather than calling undefined, which is // how a caller feature-detects across a substrate. if (typeof this.wallet.multiplyPoint !== 'function') { - throw new Error('multiplyPoint is not implemented by this wallet') + throw new TypeError('multiplyPoint is not implemented by this wallet') } const multiplyPointResult = await this.wallet.multiplyPoint(args, originator)