Skip to content

Feat/delegated signing#1601

Open
tantodefi wants to merge 6 commits intoxmtp:mainfrom
tantodefi:feat/delegated-signing
Open

Feat/delegated signing#1601
tantodefi wants to merge 6 commits intoxmtp:mainfrom
tantodefi:feat/delegated-signing

Conversation

@tantodefi
Copy link
Contributor

Implement Delegated Signing for User-Funded Messages

Summary

This PR implements the backend portion of user-funded messages via delegated signing (#1599). This allows XMTP users to authorize gateways to sign payer envelopes on their behalf, enabling users to pay for their own message sending rather than relying on gateway operators.

Problem

The current payment architecture has a fundamental limitation:

  1. Client constructs a message envelope
  2. Gateway wraps it in a PayerEnvelope signed with XMTPD_PAYER_PRIVATE_KEY
  3. Network charges the payer whose key signed the envelope
  4. The payer charged is determined by who signs, not who sent the message

Users cannot pay for their own messages even if they've funded an on-chain payer balance.

Solution

Users authorize gateways on-chain, then the gateway signs on their behalf:

User → PayerRegistry.authorize(gatewayAddress, expiry)
Gateway → signs with own key + delegation info → Network validates → charges user's balance

Changes

New Package: pkg/delegation

Provides delegation verification with caching:

// ChainVerifier verifies delegations on-chain
type ChainVerifier interface {
    IsAuthorized(ctx context.Context, payer, delegate common.Address) (bool, error)
    GetDelegation(ctx context.Context, payer, delegate common.Address) (*DelegationInfo, error)
}

// CachingVerifier wraps ChainVerifier with time-based cache
type CachingVerifier struct { ... }

Caching Strategy (Time-based TTL):

  • Default TTL: 5 minutes
  • Cache hit: Return cached value if within TTL and delegation not expired
  • Cache miss: Fetch from chain and cache result

Trade-offs:

  • ✅ Reduces on-chain RPC calls significantly
  • ✅ Low memory footprint
  • ⚠️ Revocation latency: Up to TTL (5 min) for revocations to propagate

Protocol Changes

PayerEnvelope now includes optional delegation field:

DelegatedPayerAddress []byte // 20-byte Ethereum address of actual payer

When set:

  • PayerSignature is from the gateway (delegate)
  • Fees are charged to DelegatedPayerAddress (user)

Envelope Validation (pkg/envelopes/payer.go)

New helper methods:

  • IsDelegated() bool - Check if using delegated signing
  • GetDelegatedPayerAddress() *common.Address - Get delegated payer if set
  • GetActualPayer() (*common.Address, error) - Get address to charge (resolves delegation)

Gateway Payer Service (pkg/api/payer/service.go)

signClientEnvelope() now supports delegated signing:

  1. If delegatedPayerAddress is provided, verify delegation on-chain (with caching)
  2. If valid, include DelegatedPayerAddress in the envelope
  3. If invalid or error, fall back to gateway payment

Dual Rate Limiter (pkg/ratelimiter)

New DualRateLimiter interface applies limits in parallel:

type DualRateLimiter interface {
    AllowDual(ctx context.Context, gatewaySubject, userSubject string, cost uint64) (*DualResult, error)
}

This enables:

  • Gateway limits: Overall throughput cap for the gateway
  • User limits: Per-user message rate limits (lower than gateway)

When a delegated request comes in, both limits must pass.

Files Changed

File Description
pkg/constants/constants.go New domain separator constant
pkg/delegation/delegation.go Caching delegation verifier
pkg/delegation/chain_verifier.go On-chain delegation verifier
pkg/delegation/delegation_test.go Delegation tests
pkg/api/payer/service.go Delegated signing support in gateway
pkg/envelopes/payer.go Delegation helper methods
pkg/ratelimiter/interface.go Dual limiter interface
pkg/ratelimiter/dual_limiter.go Dual limiter implementation
pkg/ratelimiter/dual_limiter_test.go Dual limiter tests

Testing

  • All delegation tests passing (6 tests)
  • Dual rate limiter tests passing
  • Existing tests unaffected

Future Work (SDK Changes Required)

The following SDK changes are needed to fully enable user-funded messages:

Client SDK:

  • authorizeGateway(gatewayAddress, expiry?) - Grant delegation
  • revokeGateway(gatewayAddress) - Revoke delegation
  • getPayerBalance(address) - Query user's payer balance
  • deposit(amount) - Deposit funds to payer registry

API Endpoints:

  • POST /payer/authorize - Create delegation
  • DELETE /payer/authorize/{gateway} - Revoke delegation
  • GET /payer/balance - Get user balance
  • GET /payer/delegations - List delegations

Security Considerations

  1. Delegation expiry: Always set reasonable expiry times
  2. Revocation latency: Account for cache TTL (5 min default) in security models
  3. Gateway trust: Users should only authorize trusted gateways
  4. Rate limiting: Per-user limits prevent abuse of delegated accounts

Related

@tantodefi tantodefi requested review from a team as code owners February 4, 2026 19:33
@macroscopeapp
Copy link

macroscopeapp bot commented Feb 4, 2026

Add delegated signing to payer service and wire unified contract config loading with standardized XMTPD_* environment variables across CLI and validators

Introduce delegated payer verification and DelegatedPayerAddress in PayerEnvelope, add caching and chain verifier, and adopt a unified contracts config loader with new environment bindings and deprecation warnings.

📍Where to Start

Start with NewPayerAPIService and delegated signing flow in [file:pkg/api/payer/service.go].


📊 Macroscope summarized f1796d2. 13 files reviewed, 16 issues evaluated, 10 issues filtered, 3 comments posted. View details

return fetchURL(path)
}

// Handle file:// URLs
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

config/loader.go:90 file:// handling with strings.TrimPrefix is brittle and breaks Windows drive-letter and UNC paths. Suggest using url.Parse and deriving a platform-correct path (via u.Path/u.Host and filepath) before os.ReadFile.

🚀 Want me to fix this? Reply ex: "fix it for me".

options.AppChain.IdentityUpdateBroadcasterAddress = config.IdentityUpdateBroadcaster
options.AppChain.IdentityUpdateBroadcasterAddress = loaded.AppChain.IdentityUpdateBroadcasterAddress
}
if options.AppChain.ChainID == 0 || options.AppChain.ChainID == 31337 {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

config/validation.go:220 Treating 31337 as “unset” in mergeContractsOptions causes explicit ChainID=31337 (app and settlement) to be overwritten by loaded config. Suggest tracking whether the flag/value was explicitly set (e.g., a separate boolean) and only treating 0 as unset, or remove the 31337 check. If intentional, document this behavior.

🚀 Want me to fix this? Reply ex: "fix it for me".

Comment on lines +84 to +88
if !p.IsDelegated() {
return nil
}
addr := common.BytesToAddress(p.proto.DelegatedPayerAddress)
return &addr
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low

envelopes/payer.go:84 Consider validating that DelegatedPayerAddress is exactly 20 bytes before calling BytesToAddress. Otherwise, malformed inputs get silently padded/cropped, potentially charging fees to the wrong address.

Suggested change
if !p.IsDelegated() {
return nil
}
addr := common.BytesToAddress(p.proto.DelegatedPayerAddress)
return &addr
if !p.IsDelegated() || len(p.proto.DelegatedPayerAddress) != 20 {
return nil
}
addr := common.BytesToAddress(p.proto.DelegatedPayerAddress)
return &addr

🚀 Want me to fix this? Reply ex: "fix it for me".

Copy link
Contributor

I like the idea at a high level. Think we'll have to take some time to digest it before adopting it.

Love the thinking here, though. Being able to pay your own way without having to sign every message

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