feat(node): optional external buyer payment signer, decoupled from the libp2p identity - #972
Closed
eladmint wants to merge 1 commit into
Closed
feat(node): optional external buyer payment signer, decoupled from the libp2p identity#972eladmint wants to merge 1 commit into
eladmint wants to merge 1 commit into
Conversation
…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
force-pushed
the
feat/external-buyer-signer
branch
from
September 1, 2026 09:11
5c68ad3 to
db8cd99
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
An optional
payments.buyerSigneronNodeConfig. When set, that signer becomes the on-chainbuyer: it signs the EIP-712
ReserveAuthandSpendingAuthmessages, and its address is thebuyerrecorded in channels. The libp2p identity keeps peer identity and signed peer metadata.Additive:
buyerSigneris optional and the default path is unchanged. Two files —packages/node/src/node.tsand aCHANGELOG.mdentry — plus a new test file.Why
Today
AntseedNodeuses its libp2p identity wallet as the on-chain buyer:Identityrequires a raw private key, since the peerId is derived from it. That means being abuyer 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:
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-coreis also already agnostic:BuyerIdentity.walletis typedBuyerSigner = AbstractSigner & { readonly address: string }, andBuyerPaymentManagersetsthis._signer = identity.wallet, so signer and buyer address agree by construction. Thecoupling 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_signeronly — the buyer addressstill comes from
_identity.wallet.address. SinceAntseedChannels._verifySignaturedoes astrict
ECDSA.recover(digest, sig) == channel.buyerwith no ERC-1271 fallback, a signer whoseaddress differs from the identity would produce
InvalidSignatureonreserve().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:
BuyerPaymentNegotiatormust receive the same payment identity.handle402()prechecks thedeposit balance of
_identity.wallet.address; with an external signer holding the funds, thelibp2p identity reads zero and every paid request fails
insufficient_depositsbefore anyReserveAuth is signed.
BuyerFreeUsageManagermust use it too, since free channels persist under that address andgetBuyerUsageTotals()queries it — otherwise free-usage traffic silently drops out of thetotals.
matching whoever signs.
Both variables are
BuyerIdentity, so passing the wrong one is invisible totsc. The testscover this by starting a real buyer node and asserting on the objects
_startBuyerbuilt,rather than rebuilding the wiring themselves — see Testing.
Scope note
buyerSigneris node-level. The desktop credits panel and the CLIbalance,channelsandmeteringsurfaces readidentity.wallet.addressas the buyer directly; they are unaffectedtoday because nothing sets
buyerSigner, but they would need to follownode.buyerAddressforthose 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:
AntseedDepositRelay.sweepDeposit(...)perdocs/protocol/spec/09-deposit-sweep.md. The buyer signs exactly one thing, an EIP-3009ReceiveWithAuthorizationon the USDC domain addressed to the relay; a relayer submits itand takes the immutable
FEE(verified on Base mainnet at0x34a44542e76f9b4cff3a31902eDF14AbF2C3B3DD:FEE()returns50000, i.e. $0.05). Puretyped-data signing, no gas, no transaction.
setOperatoris EIP-712 meta-transactional(
AntseedDeposits.sol:215-233), so the signer can authorize an operator to withdraw on itsbehalf, again without gas.
AntseedDeposits.deposit(buyer, amount)also pulls frommsg.sender, so any funded partycan top a buyer up directly.
So the constraint is narrow: only the node's own
deposit()/withdraw()helpers areunavailable to such a signer, and the doc comment on
buyerSignersays so. Worth statingbecause it is the first question an integrator will ask.
Testing
New file
packages/node/tests/external-buyer-signer.test.ts— 24 tests, reusing your existingcreateTestIdentity/ mock-payment-mux fixtures and the mock set frombuyer-payment-negotiator.test.ts.The new file on its own: 24 passed.
tsc --noEmitclean on@antseed/node. Run on bothvitest pools — the package default (threads) and
--pool=forks— 5 and 3 consecutive full-suiteruns respectively, all green.
They guard the change rather than passing either way: reverting
node.tsto base makes13 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.tsis untouchedand 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:
NodeConfigwithpayments.buyerSignerset and asserts on what_startBuyeractuallyconstructed — that the negotiator, the payment manager, the free-usage manager and
node.buyerAddressall name the signer. OnlyDHTNodeandConnectionManager.initarestubbed, and only so
start()reaches the payment wiring without binding a port or diallinga 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
tscfirst time.handle402reads the balance of_identity.wallet.addressbefore signing anything, so a negotiator holding the libp2p identityreturns
insufficient_depositson every paid request. Two tests pin that: one asserts theprecheck 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 —
handle402itself is unchanged. They characterise the trap that makes the constructionassertion meaningful.)
verifyTypedDatarecovers both the ReserveAuth anda per-request SpendingAuth to the address recorded as
buyer, and recomputingkeccak256(abi.encode(recoveredBuyer, seller, salt))reproduces the channelId — the exactinvariant
_verifySignatureenforces on-chain.Read-path tests seed channel rows through
ChannelStoredirectly rather than drivingauthorizeSpending, since what they assert is which address the node reads back; that a row iswritten 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.mdupdated under Unreleased / Added, per the changelog policy inAGENTS.md.Found while building an integration that buys inference on AntSeed using ICP threshold
signatures, where the buyer key is non-exportable by construction.