Skip to content

feat(wallet): add BRC-98 ECPM semantic module - #488

Open
connormurray2 wants to merge 6 commits into
bsv-blockchain:mainfrom
connormurray2:brc-229-invert
Open

feat(wallet): add BRC-98 ECPM semantic module#488
connormurray2 wants to merge 6 commits into
bsv-blockchain:mainfrom
connormurray2:brc-229-invert

Conversation

@connormurray2

@connormurray2 connormurray2 commented Aug 20, 2026

Copy link
Copy Markdown

Revision

This PR now implements BRC-229 as the ecpm BRC-98 semantic module, replacing the earlier optional WalletInterface.multiplyPoint approach.

The existing branch and review history are preserved. Commit a1eaaa6c0 reverses the public SDK method, SDK test, and associated bundle-budget increases from the first three commits, then adds the module-based implementation.

The companion full-text BRC rewrite is posted as a review suggestion on BRCs #230.

Protocol

Applications continue to call the fixed BRC-100 getPublicKey method:

p ecpm <apply|remove> <pointHex> <logicalProtocolID>

keyID, counterparty, privileged, privilegedReason, and seekPermission remain in their existing GetPublicKeyArgs fields.

The module derives one scalar under the canonical namespace:

[securityLevel, "p ecpm <logicalProtocolID>"] + keyID + counterparty

The operation and point are deliberately excluded from the derivation identity:

  • apply returns d·P;
  • remove returns d⁻¹·P;
  • both therefore select the same scalar and round-trip exactly.

The logical protocol ID is capped at 273 characters so the canonical p ecpm derivation namespace remains within BRC-43's 280-character limit.

Wallet Toolbox semantic module hook

PermissionsModule gains an optional, backwards-compatible handler:

handleRequest?: (
  request: PermissionsModuleRequest,
  next: PermissionsModuleNext
) => Promise<unknown>

A semantic module can return the conforming BRC-100 result directly or invoke the underlying operation once through guarded next. Existing onRequest/onResponse modules remain unchanged.

ECPM returns { publicKey } directly and does not call ordinary getPublicKey, because ordinary BRC-43 derivation returns a child public key over the fixed generator (or the named counterparty's child key), not d·P.

Installable reference package

This adds @bsv/ecpm-permission-module@0.1.0 alongside BTMS and the existing wallet module packages.

Wallet hosts register it as:

const ecpm = createEcpmModule({
  keyDeriver: setup.keyDeriver,
  authorize: request => showTrustedWalletPrompt(request),
  privilegedKeyDeriver: reason => acquirePrivilegedKeyDeriver(reason)
})

const wallet = new WalletPermissionsManager(setup.wallet, adminOriginator, {
  permissionModules: { ecpm }
})

The privileged provider is optional and is acquired only after authorization. Ordinary and privileged permission grants are isolated. Level 2 grants are counterparty-scoped, seekPermission: false fails without a cached grant, and concurrent equivalent prompts are deduplicated.

Security boundary

  • Only getPublicKey is accepted under p ecpm; signing, HMAC, and encryption cannot reuse the scalar.
  • Identity-key and forSelf: true requests are rejected.
  • Points and public-key counterparties must be canonical lowercase compressed secp256k1 points.
  • The x-coordinate is range-checked before parsing, preventing reducing parsers from accepting a different point.
  • The application receives only { publicKey }; key derivers, roots, and private scalars remain in trusted wallet/module code.
  • Privileged derivation fails closed when the host does not provide it.

Verification

  • full workspace build: pass
  • full workspace typecheck: pass
  • full workspace tests: pass
  • repo lint and formatting: pass
  • ECPM unit/property tests: 41 pass
  • ECPM coverage: 98.52% statements / 97.32% branches / 98.42% lines
  • ECPM mutation score: 86.44% (255 killed, 40 survived, 0 no-coverage)
  • exact-tarball browser contract: pass
    • Vite: 104,734 raw / 36,028 gzip / 30,031 brotli
    • esbuild: 108,443 raw / 41,505 gzip / 35,604 brotli
  • exact packed ESM consumer and declaration checks: pass
  • cross-package version governance: pass

The repository health run exposed four expired documentation reviews with real
source drift. They were reverified against the current route registries,
Dockerfiles, environment examples, package manifests, and intervening commits;
the corrected service versions, health routes, deployment guidance, and cloud
environment names are included in 4632f51f9. Documentation policy and the
complete rendered docs/link build now pass.

The final repository-health evaluator is still blocked by three pre-existing
governance exceptions whose owner review dates expired on 2026-08-23/24:
scorecard-release-token-permission,
image-size-unpublished-advisory-patch, and
legacy-sonar-analysis-suppressions. Renewing or resolving security and
quality exceptions requires an explicit maintainer governance review; this PR
does not extend them merely to make CI green.

…-side

Supersedes bsv-blockchain#487, which was overbuilt. That branch added two methods, a wire call
code and substrate plumbing across six transports. This is the single operation
actually missing, and nothing else.

Context. Masking a point through a BRC-100 wallet is already possible today, via
revealCounterpartyKeyLinkage plus decrypt, and masks produced that way commute
across independent wallets. What has no route through the interface is removing
a mask: that needs multiplication by the modular inverse of the derived key.
Feeding a*C back through the linkage recipe yields a^2*C, not C.

The primitives already exist and this method composes them rather than
introducing anything new:

  const masked   = new PublicKey(key.deriveSharedSecret(point))
  const inverse  = new PrivateKey(key.invm(new Curve().n))
  const unmasked = inverse.deriveSharedSecret(masked)   // === point

That composition requires the private key in application memory. WalletInterface
exposes 29 methods and no route to a scalar -- keyDeriver is a property of the
in-process class, not part of the interface, so over a substrate there is none.
An application whose keys live in a wallet therefore cannot complete the second
step. This method runs both steps where the key already is. The first test
asserts the output is identical to the composition above, so the behaviour is
pinned to the existing primitives rather than to a new definition.

Optional, deliberately. BRC-100's value is that it does not change, so a method
added later cannot be mandatory: declaring it required on WalletInterface broke
23 call sites across every substrate plus the KV store, registry and identity
clients, and declaring it required on ProtoWallet broke @bsv/wallet-toolbox,
where Wallet, PrivilegedKeyManager and the wallet managers satisfy the class
structurally without extending it. Applications feature-detect and degrade. Wire
substrate support is deliberately excluded here; it needs a call code, which is
an interface-version decision rather than a library one.

Key derivation is mandatory rather than stylistic. For a counterparty point Q,
d*Q IS the ECDH shared secret with Q, so performing this with a spending or
identity key would hand any caller that secret and break encryption to that
counterparty. The key is always derived from protocolID/keyID/counterparty and
no identityKey option is offered.

On validation: PublicKey.fromString accepts '02' + 'ff'.repeat(32), an
x-coordinate greater than the field prime, reduces it silently to 0x1000003d0,
and validate() then returns true. A test asserts both halves of that so the
reason for the range check is visible. The check runs before the parser, since
the parser performs the reduction. go-sdk has the same behaviour independently.

Verified: tsc -b clean, oxlint --deny-warnings clean, prettier clean on the
files this adds to (the two existing warnings in Wallet.interfaces.ts predate
it), full sdk suite green at 157 suites / 5925 tests, and wallet-toolbox at its
4 pre-existing TS2307 baseline with 0 attributable here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Connor Murray and others added 3 commits August 20, 2026 10:29
CI failure on bsv-blockchain#488 was six lanes -- merge-gate, build-and-test, browser
packages, SDK coverage, wallet browser and wallet mobile -- all one cause: bundle
size budgets, and zero type errors anywhere.

Measured rather than guessed. Building the UMD bundle from main's SDK gives
554475 bytes against a 555000 budget, so the ratchet was sitting 525 bytes above
head. multiplyPoint adds 963, which crosses it. These budgets are deliberate
ratchets set just above current size, so any real addition trips them and the
fix is to advance them by what the addition actually costs.

Reduced the cost before raising anything: the error messages carried a redundant
'the supplied'/'the result is' phrasing that bought no diagnostic value, since
the stack already names the function. Trimming those took the delta from 1043 to
963 bytes. Tests still pass -- they match on the specific part of each message,
not the prose.

Six raw budgets advanced to the next 1000-byte boundary above the observed size,
matching the existing convention:

  sdk umd          555000 -> 556000  (observed 555438)
  sdk esbuild      560000 -> 561000  (observed 560710)
  sdk vite         742000 -> 743000  (observed 742268)
  message-box umd  510000 -> 511000  (observed 510105)
  wallet client    1607000 -> 1608000 (observed 1607943)
  wallet mobile    3367000 -> 3368000 (observed 3367997)

Only the raw dimension moves. The checker throws on the first dimension over
budget, which would have meant discovering these one CI round at a time, so I
instrumented it locally to print every measurement at once and then restored it
unmodified. That surfaced the esbuild and vite overages before CI reported them.
Compressed dimensions have far more slack -- gzip sits 2808 under and brotli
4044 under, against 562 for raw -- because a kilobyte of new source compresses to
a few hundred bytes. CI agrees: every failing lane named raw and nothing else.

Verified: sdk test:browser passes the full exact-tarball browser contract, tsc -b
clean, oxlint clean repo-wide, prettier clean, and the multiplyPoint suite green
at 10 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mension

Second round on the same cause, so this time it covers the whole surface rather
than only what CI named.

CI reported two more overages after the first budget commit: esbuild browser raw
at 1253340 against 1252500, and Hermes mobile bytecode gzip at 1366133 against
1366000. Zero type errors, again -- these are purely size ratchets.

The Hermes gzip breach is the informative one. My earlier reasoning was that only
the raw dimension could realistically breach, because a kilobyte of new source
compresses to a few hundred bytes. That held for the SDK, where gzip had 2808
bytes of slack, but it is wrong here: Hermes gzip was cut to 133 bytes above
head. These budgets are set that fine on every dimension, so whichever is
tightest breaches first and patching one at a time invites another CI round for
the same kilobyte of code.

So both breached dimensions are raised to the next 500-byte step above the
observed value, and the sibling dimensions on the same bundles get the same small
allowance -- vite and esbuild gzip/brotli on the client, hermes brotli and the
whole metro triple on mobile. Every increase is 500 to 1500 bytes, proportional
to the roughly one kilobyte of source multiplyPoint adds, and none of them
loosens a budget beyond what that growth accounts for.

I tried to measure these locally rather than infer them, instrumenting
check-wallet-toolbox-platform.mjs to print every dimension the way I did for the
SDK checker. The wallet lanes pack a tarball and resolve it as an external
consumer, which needs CI's setup, so the run fails before measuring. The script
is restored unmodified -- confirmed by an empty diff under scripts/.

Verified: oxlint clean repo-wide, sdk tsc -b clean, the multiplyPoint suite green
at 10 tests, prettier clean on both budget files, the SDK exact-tarball browser
contract still passing at raw 555438 / gzip 159192 / brotli 131956, and the diff
containing nothing but the two budget files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ty-everett ty-everett changed the title feat(sdk): add optional multiplyPoint for wallet-side point masking feat(wallet): add BRC-98 ECPM semantic module Aug 25, 2026
@ty-everett

Copy link
Copy Markdown
Collaborator

Revised this existing PR/branch in place; no replacement PR was opened.

  • a1eaaa6c0 replaces the optional SDK multiplyPoint method with the
    installable BRC-98 p ecpm semantic module and the backwards-compatible
    Wallet Toolbox handleRequest hook.
  • 4632f51f9 substantively re-verifies the four service/reference pages that
    expired during this run; docs policy, render, and link validation now pass.
  • The complete BRC-229 replacement is posted as an apply-ready review
    suggestion: BRC-229: Wallet-Native Elliptic Curve Point Multiplication BRCs#230 (comment)

Local verification is recorded in the updated PR description, including the
full workspace suite, 98.52% ECPM statement coverage, 86.44% mutation score,
browser budgets, and exact packed consumers.

The remaining repository-health failure is base governance state, not a feature
failure: three security/quality exceptions reached their owner-review dates on
2026-08-23/24. I have not renewed those exceptions without the required
maintainer governance review.

@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants