Skip to content

Commit 4dc5481

Browse files
authored
Merge pull request #47 from gosuda/fix/eip3009-payment-binding
feat: implement EIP-3009 payment validation and authorization checks
2 parents d3d61cf + 7faf80a commit 4dc5481

5 files changed

Lines changed: 404 additions & 55 deletions

File tree

facilitator/evm.go

Lines changed: 193 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ type EVMFacilitator struct {
3535
address common.Address
3636
}
3737

38+
const eip3009DeadlineBuffer int64 = 6
39+
3840
func NewEVMFacilitator(network string, url string, privateKeyHex string) (*EVMFacilitator, error) {
3941
if network == "" && url == "" {
4042
return nil, fmt.Errorf("network or rpc url must be provided")
@@ -161,6 +163,13 @@ func (t *EVMFacilitator) verifyWithEndpointFallback(ctx context.Context, operati
161163

162164
// Verify detects the payload type and routes to the appropriate verification method.
163165
func (t *EVMFacilitator) Verify(ctx context.Context, payload *types.PaymentPayload, req *types.PaymentRequirements) (*types.PaymentVerifyResponse, error) {
166+
if payload == nil || req == nil {
167+
return &types.PaymentVerifyResponse{
168+
IsValid: false,
169+
InvalidReason: types.ErrInvalidPayloadFormat.Error(),
170+
}, nil
171+
}
172+
164173
raw, err := json.Marshal(payload.Payload)
165174
if err != nil {
166175
return &types.PaymentVerifyResponse{
@@ -180,6 +189,18 @@ func (t *EVMFacilitator) Verify(ctx context.Context, payload *types.PaymentPaylo
180189

181190
// Settle detects the payload type and routes to the appropriate settlement method.
182191
func (t *EVMFacilitator) Settle(ctx context.Context, payload *types.PaymentPayload, req *types.PaymentRequirements) (*types.PaymentSettleResponse, error) {
192+
network := types.Network("")
193+
if req != nil {
194+
network = types.Network(req.Network)
195+
}
196+
if payload == nil || req == nil {
197+
return &types.PaymentSettleResponse{
198+
Success: false,
199+
ErrorReason: types.ErrInvalidPayloadFormat.Error(),
200+
Network: network,
201+
}, nil
202+
}
203+
183204
raw, err := json.Marshal(payload.Payload)
184205
if err != nil {
185206
return &types.PaymentSettleResponse{
@@ -193,6 +214,116 @@ func (t *EVMFacilitator) Settle(ctx context.Context, payload *types.PaymentPaylo
193214
return t.settleEIP3009(ctx, payload, req, raw)
194215
}
195216

217+
func (t *EVMFacilitator) validateEVMPaymentEnvelope(payload *types.PaymentPayload, req *types.PaymentRequirements, payer string) *types.PaymentVerifyResponse {
218+
if payload == nil || req == nil {
219+
return &types.PaymentVerifyResponse{
220+
IsValid: false,
221+
InvalidReason: types.ErrInvalidPayloadFormat.Error(),
222+
Payer: payer,
223+
}
224+
}
225+
if payload.Accepted.Scheme != string(t.scheme) || req.Scheme != string(t.scheme) {
226+
return &types.PaymentVerifyResponse{
227+
IsValid: false,
228+
InvalidReason: types.ErrIncompatibleScheme.Error(),
229+
Payer: payer,
230+
}
231+
}
232+
if payload.Accepted.Network != t.network || req.Network != t.network {
233+
return &types.PaymentVerifyResponse{
234+
IsValid: false,
235+
InvalidReason: types.ErrNetworkMismatch.Error(),
236+
Payer: payer,
237+
}
238+
}
239+
if !strings.EqualFold(strings.TrimSpace(payload.Accepted.Asset), strings.TrimSpace(req.Asset)) {
240+
return &types.PaymentVerifyResponse{
241+
IsValid: false,
242+
InvalidReason: types.ErrTokenMismatch.Error(),
243+
Payer: payer,
244+
}
245+
}
246+
if payload.Accepted.Amount != req.Amount {
247+
return &types.PaymentVerifyResponse{
248+
IsValid: false,
249+
InvalidReason: types.ErrAmountMismatch.Error(),
250+
Payer: payer,
251+
}
252+
}
253+
if !evmAddressMatches(payload.Accepted.PayTo, req.PayTo) {
254+
return &types.PaymentVerifyResponse{
255+
IsValid: false,
256+
InvalidReason: types.ErrRecipientMismatch.Error(),
257+
Payer: payer,
258+
}
259+
}
260+
return nil
261+
}
262+
263+
func validateEIP3009Authorization(auth *evm.Authorization, req *types.PaymentRequirements, payer string) *types.PaymentVerifyResponse {
264+
if auth == nil || auth.Value == nil || auth.ValidAfter == nil || auth.ValidBefore == nil || req == nil {
265+
return &types.PaymentVerifyResponse{
266+
IsValid: false,
267+
InvalidReason: types.ErrInvalidPayloadFormat.Error(),
268+
Payer: payer,
269+
}
270+
}
271+
272+
if !common.IsHexAddress(strings.TrimSpace(req.PayTo)) || auth.To != common.HexToAddress(strings.TrimSpace(req.PayTo)) {
273+
return &types.PaymentVerifyResponse{
274+
IsValid: false,
275+
InvalidReason: types.ErrRecipientMismatch.Error(),
276+
Payer: payer,
277+
}
278+
}
279+
280+
reqAmount, ok := new(big.Int).SetString(req.Amount, 10)
281+
if !ok || reqAmount.Sign() <= 0 || auth.Value.Cmp(reqAmount) != 0 {
282+
return &types.PaymentVerifyResponse{
283+
IsValid: false,
284+
InvalidReason: types.ErrAmountMismatch.Error(),
285+
Payer: payer,
286+
}
287+
}
288+
289+
now := time.Now().Unix()
290+
if auth.ValidBefore.Cmp(big.NewInt(now+eip3009DeadlineBuffer)) < 0 {
291+
return &types.PaymentVerifyResponse{
292+
IsValid: false,
293+
InvalidReason: types.ErrAuthorizationExpired.Error(),
294+
Payer: payer,
295+
}
296+
}
297+
if auth.ValidAfter.Cmp(big.NewInt(now)) > 0 {
298+
return &types.PaymentVerifyResponse{
299+
IsValid: false,
300+
InvalidReason: types.ErrAuthorizationNotYetValid.Error(),
301+
Payer: payer,
302+
}
303+
}
304+
305+
return nil
306+
}
307+
308+
func evmAddressMatches(left string, right string) bool {
309+
left = strings.TrimSpace(left)
310+
right = strings.TrimSpace(right)
311+
if !common.IsHexAddress(left) || !common.IsHexAddress(right) {
312+
return false
313+
}
314+
return common.HexToAddress(left) == common.HexToAddress(right)
315+
}
316+
317+
func evmSettleResponseFromInvalid(invalid *types.PaymentVerifyResponse, network types.Network) *types.PaymentSettleResponse {
318+
return &types.PaymentSettleResponse{
319+
Success: false,
320+
ErrorReason: invalid.InvalidReason,
321+
ErrorMessage: invalid.InvalidMessage,
322+
Payer: invalid.Payer,
323+
Network: network,
324+
}
325+
}
326+
196327
func (t *EVMFacilitator) Supported() *types.SupportedResponse {
197328
return &types.SupportedResponse{
198329
Kinds: []types.SupportedKind{{
@@ -233,45 +364,39 @@ func (t *EVMFacilitator) verifyEIP3009(ctx context.Context, payload *types.Payme
233364
InvalidReason: types.ErrInvalidPayloadFormat.Error(),
234365
}, nil
235366
}
367+
auth := evmPayload.Authorization
368+
payer := auth.From.String()
236369

237-
// Step 2: Scheme verification (scheme lives inside payload.Accepted in v2).
238-
if payload.Accepted.Scheme != string(t.scheme) || req.Scheme != string(t.scheme) {
239-
return &types.PaymentVerifyResponse{
240-
IsValid: false,
241-
InvalidReason: types.ErrIncompatibleScheme.Error(),
242-
Payer: evmPayload.Authorization.From.String(),
243-
}, nil
370+
// Step 2: Bind client-echoed requirements to the server requirements.
371+
if invalid := t.validateEVMPaymentEnvelope(payload, req, payer); invalid != nil {
372+
return invalid, nil
373+
}
374+
if invalid := validateEIP3009Authorization(auth, req, payer); invalid != nil {
375+
return invalid, nil
244376
}
245377

246378
// Step 3: Network info and Contract info
247-
if payload.Accepted.Network != t.network {
248-
return &types.PaymentVerifyResponse{
249-
IsValid: false,
250-
InvalidReason: types.ErrNetworkMismatch.Error(),
251-
Payer: evmPayload.Authorization.From.String(),
252-
}, nil
253-
}
254-
chainID := evm.GetChainID(payload.Accepted.Network)
379+
chainID := evm.GetChainID(req.Network)
255380
if chainID == nil {
256381
return &types.PaymentVerifyResponse{
257382
IsValid: false,
258383
InvalidReason: types.ErrInvalidNetwork.Error(),
259-
Payer: evmPayload.Authorization.From.String(),
384+
Payer: payer,
260385
}, nil
261386
}
262387
if chainID.Cmp(t.networkID) != 0 {
263388
return &types.PaymentVerifyResponse{
264389
IsValid: false,
265390
InvalidReason: types.ErrNetworkIDMismatch.Error(),
266-
Payer: evmPayload.Authorization.From.String(),
391+
Payer: payer,
267392
}, nil
268393
}
269-
domainConfig := evm.GetDomainConfig(payload.Accepted.Network, req.Asset)
394+
domainConfig := evm.GetDomainConfig(req.Network, req.Asset)
270395
if domainConfig == nil {
271396
return &types.PaymentVerifyResponse{
272397
IsValid: false,
273398
InvalidReason: types.ErrTokenMismatch.Error(),
274-
Payer: evmPayload.Authorization.From.String(),
399+
Payer: payer,
275400
}, nil
276401
}
277402

@@ -280,7 +405,7 @@ func (t *EVMFacilitator) verifyEIP3009(ctx context.Context, payload *types.Payme
280405
if err != nil {
281406
return nil, err
282407
}
283-
digest := evm.HashEip3009(evmPayload.Authorization, domainConfig)
408+
digest := evm.HashEip3009(auth, domainConfig)
284409
pubkey, err := evm.Ecrecover(digest, sig)
285410
if err != nil {
286411
return nil, err
@@ -289,50 +414,55 @@ func (t *EVMFacilitator) verifyEIP3009(ctx context.Context, payload *types.Payme
289414
return &types.PaymentVerifyResponse{
290415
IsValid: false,
291416
InvalidReason: types.ErrInvalidSignature.Error(),
292-
Payer: evmPayload.Authorization.From.String(),
417+
Payer: payer,
293418
}, nil
294419
}
295-
if evm.PubkeyToAddress(pubkey) != evmPayload.Authorization.From {
420+
if evm.PubkeyToAddress(pubkey) != auth.From {
296421
return &types.PaymentVerifyResponse{
297422
IsValid: false,
298423
InvalidReason: types.ErrInvalidSignature.Error(),
299-
Payer: evmPayload.Authorization.From.String(),
424+
Payer: payer,
300425
}, nil
301426
}
302427

303-
// Step 5: Validate payTo
304-
305-
// Step 6: Deadline check
306-
307-
// Step 7: TODO: Nonce freshness check (optional in v1)
308-
309-
// Step 8: Check ERC20 balance
428+
// Step 5: Check nonce freshness
310429
contract, err := eip3009.NewEip3009(domainConfig.VerifyingContract, client)
311430
if err != nil {
312431
return nil, fmt.Errorf("contract bind failed: %w", err)
313432
}
314-
balance, err := contract.BalanceOf(&bind.CallOpts{Context: ctx}, evmPayload.Authorization.From)
433+
used, err := contract.AuthorizationState(&bind.CallOpts{Context: ctx}, auth.From, auth.Nonce)
434+
if err != nil {
435+
return nil, fmt.Errorf("failed to get authorization state: %w", err)
436+
}
437+
if used {
438+
return &types.PaymentVerifyResponse{
439+
IsValid: false,
440+
InvalidReason: types.ErrAuthorizationAlreadyUsed.Error(),
441+
Payer: payer,
442+
}, nil
443+
}
444+
445+
// Step 6: Check ERC20 balance
446+
balance, err := contract.BalanceOf(&bind.CallOpts{Context: ctx}, auth.From)
315447
if err != nil {
316448
return nil, fmt.Errorf("failed to get balance: %w", err)
317449
}
318-
if balance.Cmp(evmPayload.Authorization.Value) < 0 {
450+
if balance.Cmp(auth.Value) < 0 {
319451
return &types.PaymentVerifyResponse{
320452
IsValid: false,
321453
InvalidReason: types.ErrInsufficientBalance.Error(),
322-
Payer: evmPayload.Authorization.From.String(),
454+
Payer: payer,
323455
}, nil
324456
}
325457

326-
// Step 9: Check value in permit matches requirement
327-
328-
// Step 10: TODO: Check minimum payment threshold (e.g. for gas overhead)
458+
// Step 7: TODO: Check minimum payment threshold (e.g. for gas overhead)
329459

330-
// Step 11: TODO: Check if resource already paid (next version)
460+
// Step 8: TODO: Check if resource already paid (next version)
331461

332462
// ✅ All checks passed
333463
return &types.PaymentVerifyResponse{
334464
IsValid: true,
335-
Payer: evmPayload.Authorization.From.String(),
465+
Payer: payer,
336466
}, nil
337467
}
338468

@@ -356,6 +486,13 @@ func (t *EVMFacilitator) settleEIP3009(ctx context.Context, payload *types.Payme
356486
}
357487
payer := evmPayload.Authorization.From.String()
358488

489+
if invalid := t.validateEVMPaymentEnvelope(payload, req, payer); invalid != nil {
490+
return evmSettleResponseFromInvalid(invalid, network), nil
491+
}
492+
if invalid := validateEIP3009Authorization(evmPayload.Authorization, req, payer); invalid != nil {
493+
return evmSettleResponseFromInvalid(invalid, network), nil
494+
}
495+
359496
networkID := evm.GetChainID(req.Network)
360497
if networkID == nil {
361498
return &types.PaymentSettleResponse{
@@ -397,6 +534,24 @@ func (t *EVMFacilitator) settleEIP3009(ctx context.Context, payload *types.Payme
397534
Network: network,
398535
}, nil
399536
}
537+
used, err := contract.AuthorizationState(&bind.CallOpts{Context: ctx}, evmPayload.Authorization.From, evmPayload.Authorization.Nonce)
538+
if err != nil {
539+
return &types.PaymentSettleResponse{
540+
Success: false,
541+
ErrorReason: types.ErrTransactionFailed.Error(),
542+
ErrorMessage: err.Error(),
543+
Payer: payer,
544+
Network: network,
545+
}, nil
546+
}
547+
if used {
548+
return &types.PaymentSettleResponse{
549+
Success: false,
550+
ErrorReason: types.ErrAuthorizationAlreadyUsed.Error(),
551+
Payer: payer,
552+
Network: network,
553+
}, nil
554+
}
400555
clientSig, err := evm.ParseSignature(evmPayload.Signature) // client signature
401556
if err != nil {
402557
return &types.PaymentSettleResponse{

0 commit comments

Comments
 (0)