Skip to content

feat(node): optional external buyer payment signer, decoupled from the libp2p identity - #972

Closed
eladmint wants to merge 1 commit into
AntSeed:mainfrom
eladmint:feat/external-buyer-signer
Closed

feat(node): optional external buyer payment signer, decoupled from the libp2p identity#972
eladmint wants to merge 1 commit into
AntSeed:mainfrom
eladmint:feat/external-buyer-signer

Conversation

@eladmint

Copy link
Copy Markdown

What this adds

An optional payments.buyerSigner on NodeConfig. When set, that signer becomes the on-chain
buyer: it signs the EIP-712 ReserveAuth and SpendingAuth messages, and its address is the
buyer recorded in channels. The libp2p identity keeps peer identity and signed peer metadata.

Additive: buyerSigner is optional and the default path is unchanged. Two files —
packages/node/src/node.ts and a CHANGELOG.md entry — plus a new test file.

Why

Today AntseedNode uses its libp2p identity wallet as the on-chain buyer:

this._buyerPaymentManager = new BuyerPaymentManager(identity, buyerPaymentConfig, ...);

Identity requires a raw private key, since the peerId is derived from it. That means being a
buyer on AntSeed requires holding an exportable secp256k1 key.

That excludes a whole class of buyer: threshold-signature wallets, MPC custody, HSM-backed
keys, and custodial platforms paying on a user's behalf. All of them can produce a valid
secp256k1 signature over a digest; none can hand you the key. Peer identity and payment
authority are different concerns — a peerId is a network address, not money — and this change
lets them be different keys when the operator wants that.

Your own spec already separates these. docs/protocol/spec/06-security-overview.md §4
"Cryptographic Control Plane" lists them as two rows with different scopes:

Use Case Primitive Scope
Node identity secp256k1 keypair (EVM address) Peer ID and metadata signing
Payment auth (on-chain) EIP-712 ECDSA (ReserveAuth, SpendingAuth) Reserve, cumulative spend, settlement authorizations

The implementation currently satisfies both rows with one key. This change lets an operator
supply a different key for the second row, which is what the table already describes as a
separate concern.

@antseed/buyer-core is also already agnostic: BuyerIdentity.wallet is typed
BuyerSigner = AbstractSigner & { readonly address: string }, and BuyerPaymentManager sets
this._signer = identity.wallet, so signer and buyer address agree by construction. The
coupling is only in node.ts, which is why the patch is small.

The one subtlety

setSigner() looks like it already does this, but it swaps _signer only — the buyer address
still comes from _identity.wallet.address. Since AntseedChannels._verifySignature does a
strict ECDSA.recover(digest, sig) == channel.buyer with no ERC-1271 fallback, a signer whose
address differs from the identity would produce InvalidSignature on reserve().

That does not bite today because nothing in the shipped code calls setSigner — only tests,
against a mocked payment mux. This patch takes the other route: it supplies the signer as the
payment identity at construction.

Keeping them from drifting takes more than the one construction site, which is worth spelling
out because the first cut of this patch got it wrong and it type-checked perfectly:

  • BuyerPaymentNegotiator must receive the same payment identity. handle402() prechecks the
    deposit balance of _identity.wallet.address; with an external signer holding the funds, the
    libp2p identity reads zero and every paid request fails insufficient_deposits before any
    ReserveAuth is signed.
  • BuyerFreeUsageManager must use it too, since free channels persist under that address and
    getBuyerUsageTotals() queries it — otherwise free-usage traffic silently drops out of the
    totals.
  • The four buyer-address read paths resolve through a single accessor, so channel lookups keep
    matching whoever signs.

Both variables are BuyerIdentity, so passing the wrong one is invisible to tsc. The tests
cover this by starting a real buyer node and asserting on the objects _startBuyer built,
rather than rebuilding the wiring themselves — see Testing.

Scope note

buyerSigner is node-level. The desktop credits panel and the CLI balance, channels and
metering surfaces read identity.wallet.address as the buyer directly; they are unaffected
today because nothing sets buyerSigner, but they would need to follow node.buyerAddress for
those views to be correct under an external signer. Happy to do that in this PR or a follow-up,
whichever you prefer.

Funding a signer that cannot send transactions

BuyerPaymentManager.deposit() and .withdraw() submit real transactions through the signer,
so those two calls need transaction signing and gas. That is not a blocker for a
signTypedData-only signer, because the protocol already has a gasless path for exactly this:

  • FundingAntseedDepositRelay.sweepDeposit(...) per
    docs/protocol/spec/09-deposit-sweep.md. The buyer signs exactly one thing, an EIP-3009
    ReceiveWithAuthorization on the USDC domain addressed to the relay; a relayer submits it
    and takes the immutable FEE (verified on Base mainnet at
    0x34a44542e76f9b4cff3a31902eDF14AbF2C3B3DD: FEE() returns 50000, i.e. $0.05). Pure
    typed-data signing, no gas, no transaction.
  • WithdrawingsetOperator is EIP-712 meta-transactional
    (AntseedDeposits.sol:215-233), so the signer can authorize an operator to withdraw on its
    behalf, again without gas.
  • AntseedDeposits.deposit(buyer, amount) also pulls from msg.sender, so any funded party
    can top a buyer up directly.

So the constraint is narrow: only the node's own deposit()/withdraw() helpers are
unavailable to such a signer, and the doc comment on buyerSigner says so. Worth stating
because it is the first question an integrator will ask.

Testing

New file packages/node/tests/external-buyer-signer.test.ts — 24 tests, reusing your existing
createTestIdentity / mock-payment-mux fixtures and the mock set from
buyer-payment-negotiator.test.ts.

$ cd packages/node && node ./node_modules/vitest/vitest.mjs run
 Test Files  93 passed (93)
      Tests  1028 passed | 3 skipped (1031)

The new file on its own: 24 passed. tsc --noEmit clean on @antseed/node. Run on both
vitest pools — the package default (threads) and --pool=forks — 5 and 3 consecutive full-suite
runs respectively, all green.

They guard the change rather than passing either way: reverting node.ts to base makes
13 of the 24 fail, including all three construction tests, the buyer-address getter, the
read paths, and the free-usage totals. The existing buyer-payment-manager.test.ts is untouched
and its 64 tests still pass, including the deliberate setSigner(Wallet.createRandom()) mismatch.

Three things the tests do deliberately, because a weaker version of each would have passed while
the first cut of this patch was still broken:

  • The construction path is driven, not rebuilt. One block starts a real buyer node from a
    NodeConfig with payments.buyerSigner set and asserts on what _startBuyer actually
    constructed — that the negotiator, the payment manager, the free-usage manager and
    node.buyerAddress all name the signer. Only DHTNode and ConnectionManager.init are
    stubbed, and only so start() reaches the payment wiring without binding a port or dialling
    a peer; everything downstream is the real code. A test that rebuilds the wiring itself cannot
    catch a mis-wired constructor, which is exactly the bug that got past tsc first time.
  • The deposit precheck is covered directly. handle402 reads the balance of
    _identity.wallet.address before signing anything, so a negotiator holding the libp2p identity
    returns insufficient_deposits on every paid request. Two tests pin that: one asserts the
    precheck queries the external signer's address, and one reproduces the identity-keyed
    negotiator asking about the wrong address and failing. (Those two pass against base as well —
    handle402 itself is unchanged. They characterise the trap that makes the construction
    assertion meaningful.)
  • Signatures are recovered, not inferred. verifyTypedData recovers both the ReserveAuth and
    a per-request SpendingAuth to the address recorded as buyer, and recomputing
    keccak256(abi.encode(recoveredBuyer, seller, salt)) reproduces the channelId — the exact
    invariant _verifySignature enforces on-chain.

Read-path tests seed channel rows through ChannelStore directly rather than driving
authorizeSpending, since what they assert is which address the node reads back; that a row is
written under the signer address in the first place is asserted separately, against a real
BuyerPaymentManager.

Related

#430 ("Support multiple buyer nodes under one
funded account") asks for this same decoupling from the other direction. This patch does not
close it — its channel-scoping requirement is untouched — but it removes the identity/payment
coupling that issue runs into. #357 (deposit to a different address) and #190 (authorized
operator pattern) are existing precedent for treating the payer and the account holder as
separable.

CHANGELOG.md updated under Unreleased / Added, per the changelog policy in AGENTS.md.

Found while building an integration that buys inference on AntSeed using ICP threshold
signatures, where the buyer key is non-exportable by construction.

…bp2p identity

AntseedNode currently uses its libp2p identity wallet as the on-chain buyer.
Identity requires a raw private key, since the peerId derives from it, so being
a buyer means holding an exportable secp256k1 key. That excludes threshold
wallets, MPC custody, HSM-backed keys, and custodial platforms paying on a
user's behalf.

docs/protocol/spec/06-security-overview.md section 4 already lists node identity
(scope: peer ID and metadata signing) and payment auth (scope: reserve,
cumulative spend, settlement authorizations) as two rows with different scopes.
This adds an optional payments.buyerSigner so an operator can supply a different
key for the second row. Additive; the default path is unchanged.

Keeping signer and buyer address aligned takes more than the one construction
site, so all three buyer money components receive the payment identity
(BuyerPaymentManager, BuyerPaymentNegotiator, BuyerFreeUsageManager) and the
four buyer-address read paths resolve through one accessor. The negotiator
matters in particular: handle402 prechecks the deposit balance of
_identity.wallet.address, so passing the libp2p identity there would fail every
paid request with insufficient_deposits before any ReserveAuth is signed. Both
variables are BuyerIdentity, so getting it wrong type-checks cleanly, which is
why the tests start a real node rather than reconstructing the wiring.

deposit() and withdraw() still submit transactions through this signer and so
need gas. A signTypedData-only signer funds itself through the gasless paths
instead: AntseedDepositRelay.sweepDeposit needs only an EIP-3009
ReceiveWithAuthorization, and setOperator is EIP-712 meta-transactional.

24 tests in packages/node/tests/external-buyer-signer.test.ts. CHANGELOG updated
per the policy in AGENTS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@eladmint
eladmint force-pushed the feat/external-buyer-signer branch from 5c68ad3 to db8cd99 Compare September 1, 2026 09:11
@kotevcode kotevcode closed this Sep 2, 2026
@eladmint
eladmint deleted the feat/external-buyer-signer branch September 2, 2026 21:28
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