-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtemplate.yaml
More file actions
704 lines (674 loc) · 28.4 KB
/
Copy pathtemplate.yaml
File metadata and controls
704 lines (674 loc) · 28.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: anchor-x402 — dual-chain mainnet anchoring as an x402-paid service
Globals:
Function:
Runtime: python3.12
Architectures: [x86_64]
Timeout: 60
MemorySize: 1024
Parameters:
TreasuryAddress:
Type: String
Description: Base treasury address (receives x402 inbound, pays anchor gas)
TreasuryPrivateKey:
Type: String
NoEcho: true
Description: Base treasury private key. Consumed at deploy-time only to seed the runtime secret; never lands in Lambda env.
SolanaTreasuryAddress:
Type: String
Default: ""
Description: Solana treasury pubkey (required for pay-skills listing)
SolanaTreasuryKey:
Type: String
NoEcho: true
Default: ""
Description: Solana treasury keypair (base58). Consumed at deploy-time only to seed the runtime secret.
PolygonTreasuryAddress:
Type: String
Default: ""
Description: Polygon address that receives JPYC x402 payments. Leave empty to disable the JPYC rail.
PolygonRelayerKey:
Type: String
NoEcho: true
Default: ""
Description: Polygon EOA private key that gas-pays JPYC transferWithAuthorization settles. Holds MATIC, not JPYC. Consumed at deploy-time only to seed the runtime secret.
PolygonRpcUrl:
Type: String
Default: https://polygon-rpc.com
Description: Polygon JSON-RPC endpoint. Override with an Alchemy/Infura/QuickNode URL if the public node rate-limits.
CdpApiKeyId:
Type: String
Description: CDP API key id (non-sensitive)
CdpApiKeySecret:
Type: String
NoEcho: true
Description: CDP API key secret. Consumed at deploy-time only to seed the runtime secret.
AlarmEmail:
Type: String
Default: ""
Description: Optional email address subscribed to AlarmTopic. Leave empty to skip auto-subscription.
ApiCustomDomain:
Type: String
Default: ""
Description: Optional custom domain (e.g. api.anchor-x402.com). Leave empty to skip; a CNAME at the registrar must point at the regional output.
ApiCustomDomainCertArn:
Type: String
Default: ""
Description: ACM certificate ARN for ApiCustomDomain (must be in this region). Required when ApiCustomDomain is set.
ChatCustomDomain:
Type: String
Default: ""
Description: Optional custom domain for the chatbot UI (e.g. chat.anchor-x402.com). Leave empty to skip. Maps to the same Lambda — root path serves the chat UI.
ChatCustomDomainCertArn:
Type: String
Default: ""
Description: ACM certificate ARN for ChatCustomDomain (this region). Can be the same wildcard cert used for ApiCustomDomain if it covers both subdomains.
InvestigatorWorkerFunctionName:
Type: String
Default: risk-investigator-worker
Description: Lambda function name of the risk-investigator worker (in a separate stack).
InvestigatorJobsTable:
Type: String
Default: risk-investigator-jobs
Description: DynamoDB table name owned by the risk-investigator stack; shim writes job_id, reads status.
PublicBaseUrl:
Type: String
Default: https://api.anchor-x402.com
Description: Public base URL used to construct status_url in /v1/investigate responses.
A2aKmsKeyAlias:
Type: String
Default: alias/anchor-x402-a2a
Description: >-
KMS alias of the Ed25519 key that signs A2A quotes and receipts (keyspec
ECC_NIST_EDWARDS25519, ED25519_SHA_512). Deliberately created outside this
stack — the published identity must outlive any stack teardown, and KMS
deletion is irreversible after a 7-30 day window. Provision with
scripts/a2a-keygen.py --provision. Identity only: cannot move funds.
WcProjectId:
Type: String
Default: ""
Description: WalletConnect Cloud project id, exposed by /v1/config for the static chat UI. Leave empty to hide WC button (MetaMask still works).
BedrockRegion:
Type: String
Default: us-east-1
Description: AWS region for Bedrock model invocation (cross-region inference profiles).
BaseApiKey:
Type: String
NoEcho: true
Default: ""
Description: base.dev API key (bdev_...). Authenticates server-to-Base calls (notifications, attribution analytics). Stored in Secrets Manager.
AgentSeedPhrase:
Type: String
NoEcho: true
Default: ""
Description: Stable seed for the Agentverse agent identity (any random hex/string). Same seed = same Fetch.ai address. Stored in Secrets Manager.
InternalAuthSecret:
Type: String
NoEcho: true
Default: ""
Description: Shared secret used by the gateway-shim (Circle Gateway facade Lambda) to bypass x402 when proxying upstream to the anchor Lambda. 64-char hex. Stored in Secrets Manager.
GatewayCustomDomain:
Type: String
Default: ""
Description: Optional custom domain for the Circle Gateway shim (e.g. gateway.anchor-x402.com). Leave empty to skip.
GatewayCustomDomainCertArn:
Type: String
Default: ""
Description: ACM certificate ARN for GatewayCustomDomain (this region). Required when GatewayCustomDomain is set.
BaseRpcUrl:
Type: String
Default: https://mainnet.base.org
Description: Base mainnet JSON-RPC URL. Default is the public node which rate-limits sustained tickers — override with a paid endpoint (Alchemy, QuickNode, CDP, dRPC). The key portion lives only in the CloudFormation parameter, not in source.
AcpPulseSecretName:
Type: String
Default: "risk-investigator/acp-pulse-client"
Description: Cross-stack Secrets Manager name (created by the risk-investigator stack) holding the buyer-side EOA private key. The CdpHeartbeat Lambda reuses this wallet so there's no second funding stream to manage.
Conditions:
HasAlarmEmail: !Not [!Equals [!Ref AlarmEmail, ""]]
HasCustomDomain: !Not [!Equals [!Ref ApiCustomDomain, ""]]
HasChatCustomDomain: !Not [!Equals [!Ref ChatCustomDomain, ""]]
HasGatewayCustomDomain: !Not [!Equals [!Ref GatewayCustomDomain, ""]]
Resources:
AnchorFunction:
Type: AWS::Serverless::Function
Metadata:
BuildMethod: makefile
Properties:
CodeUri: ./
Handler: app.handler
# 300s (not the 60s global): /v1/ledger/report jobs run in this same
# function via async self-invoke and scan up to 366 days of Base logs.
# Interactive API traffic is still capped at 29s by API Gateway.
Timeout: 300
Environment:
Variables:
TREASURY_ADDRESS: !Ref TreasuryAddress
SOLANA_TREASURY_ADDRESS: !Ref SolanaTreasuryAddress
POLYGON_TREASURY_ADDRESS: !Ref PolygonTreasuryAddress
POLYGON_RPC_URL: !Ref PolygonRpcUrl
CDP_API_KEY_ID: !Ref CdpApiKeyId
BASE_RPC_URL: !Ref BaseRpcUrl
SOLANA_RPC_URL: https://api.mainnet-beta.solana.com
# Sensitive runtime values (treasury keys, CDP secret) are NOT
# in env vars — they live in Secrets Manager and are fetched
# at cold-start via services/secrets.py. The Lambda env only
# carries the resolvable ARN.
ANCHOR_SECRET_ARN: !Ref AnchorRuntimeSecret
# risk-investigator shim wiring (private orchestrator in separate stack)
INVESTIGATOR_WORKER_FUNCTION_NAME: !Ref InvestigatorWorkerFunctionName
INVESTIGATOR_JOBS_TABLE: !Ref InvestigatorJobsTable
PUBLIC_BASE_URL: !Ref PublicBaseUrl
WC_PROJECT_ID: !Ref WcProjectId
BEDROCK_REGION: !Ref BedrockRegion
LEDGER_REPORTS_BUCKET: !Ref LedgerReportsBucket
# POST /v1/a2a replay + quote store. Unset locally → in-memory
# fallback in services/a2a.py, so dev needs no AWS.
A2A_STATE_TABLE: !Ref A2AStateTable
# KMS-held Ed25519 identity key that signs quotes + receipts.
A2A_KMS_KEY_ID: !Ref A2aKmsKeyAlias
Events:
ApiEvent:
Type: HttpApi
Properties:
Path: /{proxy+}
Method: ANY
# Anchors a signed root over the live A2A receipt set to Base + Solana,
# making a receipt's existence provable against two L1s rather than only
# against our signature. Runs in this function via the a2a_root branch in
# handler(), the same self-dispatch pattern ledger_job uses. Idempotent:
# the root is content-addressed, so an unchanged receipt set is skipped
# and a double-fire costs nothing on-chain.
A2AReceiptRootSchedule:
Type: Schedule
Properties:
Schedule: cron(30 0 * * ? *)
Description: Daily 00:30 UTC — dual-chain anchor of the A2A receipt root.
Input: '{"a2a_root": true}'
Policies:
# Secrets Manager: only the runtime composite secret. CloudTrail
# logs every GetSecretValue, KMS-encrypted at rest, scoped by ARN.
- Statement:
- Effect: Allow
Action:
- secretsmanager:GetSecretValue
Resource: !Ref AnchorRuntimeSecret
# risk-investigator shim: invoke worker Lambda + read/write its jobs table
- Statement:
- Effect: Allow
Action:
- lambda:InvokeFunction
Resource: !Sub "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${InvestigatorWorkerFunctionName}"
- Statement:
- Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:Scan
Resource: !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${InvestigatorJobsTable}"
# Bedrock invocation for /v1/chat, /v1/roast, /v1/oracle, /v1/tldr,
# /v1/aura, /v1/grade. Cross-region inference profiles forward to
# foundation models across regions, so both ARNs are needed.
- Statement:
- Effect: Allow
Action: bedrock:InvokeModel
Resource:
- !Sub "arn:aws:bedrock:*::foundation-model/anthropic.claude-*"
- !Sub "arn:aws:bedrock:${AWS::Region}:${AWS::AccountId}:inference-profile/us.anthropic.claude-*"
# /v1/ledger/report: self-invoke for the async job path (wildcard on
# the generated function name — a self-reference would be circular),
# plus read/write on the rendered report files.
- Statement:
- Effect: Allow
Action: lambda:InvokeFunction
Resource: !Sub "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${AWS::StackName}-AnchorFunction-*"
- Statement:
- Effect: Allow
Action:
- s3:PutObject
- s3:GetObject
Resource: !Sub "${LedgerReportsBucket.Arn}/ledger/*"
# POST /v1/a2a: conditional PutItem is the replay defense, GetItem
# resolves a quote when a peer asks for its receipt, UpdateItem drives the
# atomic rate-limit counters. No delete — records expire by TTL only, so
# nothing can retire a nonce early.
- Statement:
- Effect: Allow
Action:
- dynamodb:PutItem
- dynamodb:GetItem
- dynamodb:UpdateItem
# Scan: the daily root job collects the live receipt set. Table is
# small and short-TTL'd, so a filtered scan is cheaper than
# maintaining an index for one reader per day.
- dynamodb:Scan
Resource: !GetAtt A2AStateTable.Arn
# A2A identity signing. Sign only — never GetPublicKey (the public key
# is already in the agent card), never Decrypt, and no key admin. The
# RequestAlias condition means this grant follows the alias, so
# rotating the key is a repoint with no redeploy and no IAM edit; a
# different key in the same account is not reachable through it.
- Statement:
- Effect: Allow
Action: kms:Sign
Resource: !Sub "arn:aws:kms:${AWS::Region}:${AWS::AccountId}:key/*"
Condition:
StringEquals:
kms:RequestAlias: !Ref A2aKmsKeyAlias
# Agent-to-agent replay + quote + receipt store. Three record kinds keyed by
# prefix: `nonce#<origin>#<nonce>` (single-use envelope guard, scoped per peer
# so one peer cannot burn another's nonce values), `quote#<exchange_id>` (what
# we quoted a peer, so its receipt is more than the peer's own word), and
# `receipt#<exchange_id>` (first-write-wins, so exactly one signed receipt can
# exist per exchange). All short-lived and reconstructible — losing this table
# costs a replay window, not data. The service refuses to serve without it.
A2AStateTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
TimeToLiveSpecification:
AttributeName: ttl
Enabled: true
# Rendered /v1/ledger/report files (markdown + CSV), served back through
# GET /reports/ledger/{job_id}.{md,csv} on the main function. Private
# bucket; the unguessable job-id URL is the access control. Files expire
# after ~13 months (docs promise 12); the on-chain anchor is permanent.
LedgerReportsBucket:
Type: AWS::S3::Bucket
Properties:
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
LifecycleConfiguration:
Rules:
- Id: expire-reports
Status: Enabled
ExpirationInDays: 400
# Note on rate limiting /v1/a2a: API Gateway per-route throttling is NOT
# available here. This stack's API is a single greedy `ANY /{proxy+}` route, so
# a RouteSettings key of "POST /v1/a2a" matches nothing, and declaring
# ServerlessHttpApi to configure the implicit API does not take effect — both
# were confirmed by inspecting the processed template of a real changeset, in
# which the stage came back with no RouteSettings at all. Throttling the
# catch-all route instead would cap all 18 paid endpoints, and AWS WAF does not
# support HTTP APIs (only REST). The limit therefore lives in the application,
# in services/a2a.py, backed by A2AStateTable so it holds across containers.
# ---------------------------------------------------------------------
# Gateway shim: Node Lambda that terminates Circle Gateway x402 payments
# and forwards to api.anchor-x402.com with an internal-auth header that
# bypasses standard x402. Lets us list one service on agents.circle.com
# while keeping all business logic in the Python upstream.
# ---------------------------------------------------------------------
GatewayHttpApi:
Type: AWS::Serverless::HttpApi
Properties:
CorsConfiguration:
AllowMethods: ["*"]
AllowHeaders: ["*"]
AllowOrigins: ["*"]
GatewayShimFunction:
Type: AWS::Serverless::Function
Metadata:
BuildMethod: makefile
Properties:
CodeUri: ./gateway-shim
Handler: handler.handler
Runtime: nodejs22.x
MemorySize: 512
Timeout: 30
Environment:
Variables:
UPSTREAM_URL: !Sub "https://${ApiCustomDomain}"
SELLER_ADDRESS: !Ref TreasuryAddress
INTERNAL_AUTH_SECRET: !Ref InternalAuthSecret
Events:
ApiEvent:
Type: HttpApi
Properties:
ApiId: !Ref GatewayHttpApi
Path: /{proxy+}
Method: ANY
# Daily backstop for the /v1/investigate auto-refund promise. The fast
# path is investigate_status refunding inline when the buyer polls; this
# catches the rare case of a FAILED job whose buyer never polled back.
RefundCronFunction:
Type: AWS::Serverless::Function
Metadata:
BuildMethod: makefile
Properties:
CodeUri: ./
Handler: services.refund_cron.handler
Timeout: 300
MemorySize: 512
Environment:
Variables:
BASE_RPC_URL: !Ref BaseRpcUrl
ANCHOR_SECRET_ARN: !Ref AnchorRuntimeSecret
INVESTIGATOR_JOBS_TABLE: "risk-investigator-jobs"
Events:
DailyRefundSweep:
Type: Schedule
Properties:
Schedule: cron(0 18 * * ? *)
Description: Daily 18:00 UTC scan for FAILED /v1/investigate jobs without a refund_tx — backstops the inline refund-on-poll path.
Policies:
- Statement:
- Effect: Allow
Action:
- secretsmanager:GetSecretValue
Resource: !Ref AnchorRuntimeSecret
- Effect: Allow
Action:
- dynamodb:Scan
- dynamodb:GetItem
- dynamodb:UpdateItem
Resource: !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/risk-investigator-jobs"
# Daily heartbeat that pays for the 8 endpoints with no other organic
# outside-buyer traffic (attest, parse/datetime, roll, roast, oracle, tldr,
# aura, grade). The InvestigatorPulse cron covers /v1/investigate and the
# investigator worker pays for 7 more during each job; these 8 would
# otherwise decay against CDP discovery's 30-day TTL. Reuses the pulse
# wallet via the shared `risk-investigator/acp-pulse-client` secret.
CdpHeartbeatFunction:
Type: AWS::Serverless::Function
Metadata:
BuildMethod: makefile
Properties:
CodeUri: ./
Handler: services.cdp_heartbeat.handler
Timeout: 300
MemorySize: 512
Environment:
Variables:
ANCHOR_X402_BASE_URL: "https://api.anchor-x402.com"
HEARTBEAT_WALLET_PRIVATE_KEY: !Sub "{{resolve:secretsmanager:${AcpPulseSecretName}:SecretString:ACP_AGENT_PRIVATE_KEY}}"
Events:
BiweeklyHeartbeat:
Type: Schedule
Properties:
Schedule: cron(0 16 */14 * ? *)
Description: Every-14-days 16:00 UTC paid probe across the endpoints with no organic outside-buyer traffic — keeps CDP discovery entries inside the 30-day active window (2x margin).
Policies:
- Statement:
- Effect: Allow
Action:
- secretsmanager:GetSecretValue
Resource: !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${AcpPulseSecretName}-*"
# Composite runtime secret. Seeded from CloudFormation parameters on
# first deploy; rotate independently afterward via:
# aws secretsmanager update-secret --secret-id <arn> \
# --secret-string '{...}'
# Lambda code reads this at cold-start (services/secrets.py) and
# caches in process memory.
AnchorRuntimeSecret:
Type: AWS::SecretsManager::Secret
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
Name: anchor-x402/runtime
Description: anchor-x402 runtime secrets — treasury wallet keys, CDP secret
SecretString: !Sub |
{
"treasury_evm_key": "${TreasuryPrivateKey}",
"treasury_solana_key": "${SolanaTreasuryKey}",
"polygon_relayer_key": "${PolygonRelayerKey}",
"cdp_api_key_secret": "${CdpApiKeySecret}",
"base_api_key": "${BaseApiKey}",
"agent_seed_phrase": "${AgentSeedPhrase}",
"internal_auth_secret": "${InternalAuthSecret}"
}
AlarmTopic:
Type: AWS::SNS::Topic
Properties:
DisplayName: anchor-x402 alarms
AlarmTopicEmailSubscription:
Type: AWS::SNS::Subscription
Condition: HasAlarmEmail
Properties:
TopicArn: !Ref AlarmTopic
Protocol: email
Endpoint: !Ref AlarmEmail
LambdaErrorsAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: !Sub '${AWS::StackName}-LambdaErrorsElevated'
AlarmDescription: AnchorFunction Lambda Errors > 5 over 5 minutes
Namespace: AWS/Lambda
MetricName: Errors
Dimensions:
- Name: FunctionName
Value: !Ref AnchorFunction
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 5
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Ref AlarmTopic
OKActions:
- !Ref AlarmTopic
LambdaDurationP95Alarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: !Sub '${AWS::StackName}-LambdaDurationP95High'
AlarmDescription: AnchorFunction Duration P95 > 25s over 5 minutes (timeout is 30s for anchor + RPC)
Namespace: AWS/Lambda
MetricName: Duration
Dimensions:
- Name: FunctionName
Value: !Ref AnchorFunction
ExtendedStatistic: p95
Period: 300
EvaluationPeriods: 1
Threshold: 25000
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Ref AlarmTopic
OKActions:
- !Ref AlarmTopic
ApiGateway5xxAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: !Sub '${AWS::StackName}-ApiGateway5xxElevated'
AlarmDescription: ServerlessHttpApi 5xx > 15 sustained across 2 of 3 five-minute windows
Namespace: AWS/ApiGateway
MetricName: 5xx
Dimensions:
- Name: ApiId
Value: !Ref ServerlessHttpApi
Statistic: Sum
Period: 300
# Was: 1 period × threshold 3 — fired on transient 5–7-error bursts that
# happen ~2-3×/day at AWS plumbing level (Lambda is healthy, errors don't
# reach our handler). Updated 2026-05-28 after 15+ false-alarm flaps in 48h.
# New: 2 of 3 periods × threshold 15 = sustained burst, real outage signal.
EvaluationPeriods: 3
DatapointsToAlarm: 2
Threshold: 15
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Ref AlarmTopic
OKActions:
- !Ref AlarmTopic
SolanaAnchorFailuresMetricFilter:
Type: AWS::Logs::MetricFilter
Properties:
LogGroupName: !Sub '/aws/lambda/${AnchorFunction}'
FilterPattern: '"solana anchor failed"'
MetricTransformations:
- MetricNamespace: anchor-x402
MetricName: SolanaAnchorFailures
MetricValue: '1'
DefaultValue: 0
SolanaAnchorFailuresAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: !Sub '${AWS::StackName}-SolanaAnchorFailureRateHigh'
AlarmDescription: '"solana anchor failed" log occurrences > 5 per 5 minutes'
Namespace: anchor-x402
MetricName: SolanaAnchorFailures
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 5
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Ref AlarmTopic
OKActions:
- !Ref AlarmTopic
# Peer-facing failures on POST /v1/a2a: signature rejects, replayed nonces,
# denied origins, unresolvable peer cards. A trickle is healthy — that is the
# door refusing bad envelopes — so the threshold is set for a sustained spike,
# which means either a peer broke its signing or someone is probing. app.py
# emits the A2A_FAIL prefix on failures only, so this stays a substring match
# (a JSON filter cannot parse the prefixed log line).
A2APeerFailuresMetricFilter:
Type: AWS::Logs::MetricFilter
Properties:
LogGroupName: !Sub '/aws/lambda/${AnchorFunction}'
FilterPattern: '"A2A_FAIL"'
MetricTransformations:
- MetricNamespace: anchor-x402
MetricName: A2APeerFailures
MetricValue: '1'
DefaultValue: 0
A2APeerFailuresAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: !Sub '${AWS::StackName}-A2APeerFailuresElevated'
AlarmDescription: 'A2A_FAIL log occurrences > 30 per 5 minutes — a peer broke its signing, or someone is probing /v1/a2a'
Namespace: anchor-x402
MetricName: A2APeerFailures
Statistic: Sum
Period: 300
# Deliberately well above normal refusal noise. The route throttle caps
# /v1/a2a at 5 rps, so this fires on a sustained pattern, not a burst —
# the 5xx alarm above had to be retuned twice for exactly that reason.
EvaluationPeriods: 1
Threshold: 30
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Ref AlarmTopic
OKActions:
- !Ref AlarmTopic
# A2A signing failures. services/a2a.py deliberately degrades to an unsigned
# payload when KMS is unreachable — availability over non-repudiation — which
# means a wrong kms:Sign grant or a bad alias would ship `signed: false`
# forever with no other symptom. Any occurrence is a configuration fault, so
# this alarms on the first one rather than on a rate.
A2ASignFailuresMetricFilter:
Type: AWS::Logs::MetricFilter
Properties:
LogGroupName: !Sub '/aws/lambda/${AnchorFunction}'
FilterPattern: '"A2A_SIGN_FAIL"'
MetricTransformations:
- MetricNamespace: anchor-x402
MetricName: A2ASignFailures
MetricValue: '1'
DefaultValue: 0
A2ASignFailuresAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: !Sub '${AWS::StackName}-A2ASigningBroken'
AlarmDescription: 'KMS signing failed for /v1/a2a — quotes and receipts are shipping unsigned. Check the kms:Sign grant and the alias.'
Namespace: anchor-x402
MetricName: A2ASignFailures
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Ref AlarmTopic
OKActions:
- !Ref AlarmTopic
ApiCustomDomainName:
Type: AWS::ApiGatewayV2::DomainName
Condition: HasCustomDomain
Properties:
DomainName: !Ref ApiCustomDomain
DomainNameConfigurations:
- CertificateArn: !Ref ApiCustomDomainCertArn
EndpointType: REGIONAL
SecurityPolicy: TLS_1_2
ApiCustomDomainMapping:
Type: AWS::ApiGatewayV2::ApiMapping
Condition: HasCustomDomain
Properties:
DomainName: !Ref ApiCustomDomainName
ApiId: !Ref ServerlessHttpApi
Stage: $default
ChatCustomDomainName:
Type: AWS::ApiGatewayV2::DomainName
Condition: HasChatCustomDomain
Properties:
DomainName: !Ref ChatCustomDomain
DomainNameConfigurations:
- CertificateArn: !Ref ChatCustomDomainCertArn
EndpointType: REGIONAL
SecurityPolicy: TLS_1_2
ChatCustomDomainMapping:
Type: AWS::ApiGatewayV2::ApiMapping
Condition: HasChatCustomDomain
Properties:
DomainName: !Ref ChatCustomDomainName
ApiId: !Ref ServerlessHttpApi
Stage: $default
GatewayCustomDomainName:
Type: AWS::ApiGatewayV2::DomainName
Condition: HasGatewayCustomDomain
Properties:
DomainName: !Ref GatewayCustomDomain
DomainNameConfigurations:
- CertificateArn: !Ref GatewayCustomDomainCertArn
EndpointType: REGIONAL
SecurityPolicy: TLS_1_2
GatewayCustomDomainMapping:
Type: AWS::ApiGatewayV2::ApiMapping
Condition: HasGatewayCustomDomain
Properties:
DomainName: !Ref GatewayCustomDomainName
ApiId: !Ref GatewayHttpApi
Stage: $default
# NOTE on /v1/chat rate limiting (2026-05-26): WAFv2 does NOT support HTTP API
# v2 as a resource target (only REST APIs, CloudFront, ALB, AppSync, Cognito,
# App Runner, Verified Access). SAM RouteSettings via HttpApi didn't propagate
# either. Current state: /v1/chat has no per-route rate limit. Bedrock service
# quota (~50 concurrent Claude calls/region) is the ultimate cost ceiling.
# Real fix when traffic justifies the work: either (a) split /v1/chat into a
# separate Lambda with reserved-concurrency cap, or (b) front the API with
# CloudFront + WAF.
Outputs:
ApiUrl:
Value: !Sub 'https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com'
Description: anchor-x402 public URL (AWS direct)
ApiCustomDomainTarget:
Condition: HasCustomDomain
Value: !GetAtt ApiCustomDomainName.RegionalDomainName
Description: Set this as the CNAME target for ApiCustomDomain at your DNS registrar
ChatCustomDomainTarget:
Condition: HasChatCustomDomain
Value: !GetAtt ChatCustomDomainName.RegionalDomainName
Description: Set this as the CNAME target for ChatCustomDomain at your DNS registrar
GatewayUrl:
Value: !Sub 'https://${GatewayHttpApi}.execute-api.${AWS::Region}.amazonaws.com'
Description: Circle Gateway shim public URL (AWS direct). Point gateway.anchor-x402.com here once tested.
GatewayCustomDomainTarget:
Condition: HasGatewayCustomDomain
Value: !GetAtt GatewayCustomDomainName.RegionalDomainName
Description: Set this as the CNAME target for GatewayCustomDomain at your DNS registrar