golem-vm-provider 0.1.37__py3-none-any.whl → 0.1.39__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: golem-vm-provider
3
- Version: 0.1.37
3
+ Version: 0.1.39
4
4
  Summary: VM on Golem Provider Node - Run your own provider node to offer VMs on the Golem Network
5
5
  Keywords: golem,vm,provider,cloud,decentralized
6
6
  Author: Phillip Jensen
@@ -230,6 +230,29 @@ GOLEM_PROVIDER_DISCOVERY_URL="http://discovery.golem.network:9001"
230
230
  GOLEM_PROVIDER_ADVERTISEMENT_INTERVAL=240
231
231
  ```
232
232
 
233
+ ### Streaming Payments (Polygon GLM)
234
+
235
+ Enable on‑chain stream‑gated rentals by configuring the following (env prefix `GOLEM_PROVIDER_`):
236
+
237
+ - `POLYGON_RPC_URL` — Polygon PoS RPC URL (e.g., https://polygon-rpc.com)
238
+ - `STREAM_PAYMENT_ADDRESS` — StreamPayment contract address; if non‑zero, VM creation requires a valid `stream_id`
239
+ - `GLM_TOKEN_ADDRESS` — GLM ERC20 address (for info endpoint)
240
+
241
+ Optional background automation (all disabled by default):
242
+
243
+ - `STREAM_MIN_REMAINING_SECONDS` — minimum remaining runway to keep a VM running (default 3600)
244
+ - `STREAM_MONITOR_ENABLED` — stop VMs when remaining runway < threshold (default false)
245
+ - `STREAM_MONITOR_INTERVAL_SECONDS` — how frequently to check runway (default 60)
246
+ - `STREAM_WITHDRAW_ENABLED` — periodically withdraw vested funds (default false)
247
+ - `STREAM_WITHDRAW_INTERVAL_SECONDS` — how often to attempt withdrawals (default 1800)
248
+ - `STREAM_MIN_WITHDRAW_WEI` — only withdraw when >= this amount (gas‑aware)
249
+
250
+ When enabled, the provider verifies each VM creation request’s `stream_id` and refuses to start the VM if:
251
+
252
+ - stream recipient != provider’s Ethereum address
253
+ - deposit is zero, stream not started, or stream halted
254
+ - (Optional) remaining runway < `STREAM_MIN_REMAINING_SECONDS`
255
+
233
256
  ## API Reference
234
257
 
235
258
  ### Create VM
@@ -245,7 +268,8 @@ Request:
245
268
  "name": "my-webserver",
246
269
  "cpu_cores": 2,
247
270
  "memory_gb": 4,
248
- "storage_gb": 20
271
+ "storage_gb": 20,
272
+ "stream_id": 123 // required when STREAM_PAYMENT_ADDRESS is set
249
273
  }
250
274
  ```
251
275
 
@@ -273,6 +297,24 @@ Response:
273
297
  - Delete VM: `DELETE /api/v1/vms/{vm_id}`
274
298
  - Stop VM: `POST /api/v1/vms/{vm_id}/stop`
275
299
  - Get Access Info: `GET /api/v1/vms/{vm_id}/access`
300
+
301
+ ### Provider Info
302
+
303
+ ```bash
304
+ GET /api/v1/provider/info
305
+ ```
306
+
307
+ Response:
308
+
309
+ ```json
310
+ {
311
+ "provider_id": "0xProviderEthereumAddress",
312
+ "stream_payment_address": "0xStreamPayment",
313
+ "glm_token_address": "0xGLM"
314
+ }
315
+ ```
316
+
317
+ Use this endpoint to discover the correct recipient for creating a GLM stream.
276
318
 
277
319
  ## Operations
278
320
 
@@ -1,38 +1,43 @@
1
1
  provider/__init__.py,sha256=HO1fkPpZqPO3z8O8-eVIyx8xXSMIVuTR_b1YF0RtXOg,45
2
2
  provider/api/__init__.py,sha256=ssX1ugDqEPt8Fn04IymgmG-Ev8PiXLsCSaiZVvHQnec,344
3
- provider/api/models.py,sha256=hTQYzVZHJ-SD_pIpoV0KbPUghR-PUY9YKyUlETstwuQ,3567
4
- provider/api/routes.py,sha256=ZNXRunNYTu9CC1XcknOx3gHQMDEyXtVMC3fArXPJkt0,6434
5
- provider/config.py,sha256=GV-YRB9eUQXaZNFkYZRVwOSmD9BBBqtctcUw7cV8xsE,18722
6
- provider/container.py,sha256=u8A1FuG2targtBBqQlSiPW1yCTOm1tbrRQh8sIHstiM,2442
3
+ provider/api/models.py,sha256=_y3N9amTFaHfH7_3uZlvhPAoUA4HY-93f-BBuC4EoBM,3830
4
+ provider/api/routes.py,sha256=YCAJT9Cw4H5phbgsbYwppcn8H_3G1ogom6mdaPM-tHs,7683
5
+ provider/config.py,sha256=98gBZiBdzzF5AuPvEiv4N79mgf0G2zZeld2_kMJ-DKA,21673
6
+ provider/container.py,sha256=81x5LiA-qjYN1Uh_JdOxqvuIXiNDr9X3OXNN0VqYFCI,3681
7
7
  provider/discovery/__init__.py,sha256=Y6o8RxGevBpuQS3k32y-zSVbP6HBXG3veBl9ElVPKaU,349
8
- provider/discovery/advertiser.py,sha256=2-khnnzaaftiV1QfJoILkzX-ipa3m1PxSdNd7gy7Y0A,5672
9
- provider/discovery/golem_base_advertiser.py,sha256=qXIiQ0nvn7ytb6LR9KaMNFwGSE2HTY4XtMlkkAG7pj4,6093
8
+ provider/discovery/advertiser.py,sha256=o-LiDl1j0lXMUU0-zPe3qerjpoD2360EA60Y_V_VeBc,6571
9
+ provider/discovery/golem_base_advertiser.py,sha256=_UgxsedmBvSRX919wCp4Wo-pZV5fncesXj1k2h3jkXY,6869
10
10
  provider/discovery/golem_base_utils.py,sha256=xk7vznhMgzrn0AuGyk6-9N9ukp9oPdBbbk1RI-sVjp0,607
11
+ provider/discovery/multi_advertiser.py,sha256=_J79wA1-XQ4GsLzt9KrKpWigGSGBqtut7DaocIk2fyE,991
11
12
  provider/discovery/resource_monitor.py,sha256=AmiEc7yBGEGXCunQ-QKmVgosDX3gOhK1Y58LJZXrwAs,949
12
13
  provider/discovery/resource_tracker.py,sha256=MP7IXd3aIMsjB4xz5Oj9zFDTEnvrnw-Cyxpl33xcJcc,6006
13
- provider/discovery/service.py,sha256=qU_fa8znPDNk-fQZ6Z3x6HDTbopK6RgfC_D8rixqBmY,709
14
- provider/main.py,sha256=LvpJxkOS3dKLJu4Ef80jS9Umtm1vX7Pf0We68-uGp1A,6630
14
+ provider/discovery/service.py,sha256=vX_mVSxvn3arnb2cKDM_SeJp1ZgPdImP2aUubeXgdRg,915
15
+ provider/main.py,sha256=V1n7IFhV0Y2WMjD1_IomEYptdRu1G-K6U0JL8ip8Th8,11962
15
16
  provider/network/port_verifier.py,sha256=3l6WNwBHydggJRFYkAsuBp1eCxaU619kjWuM-zSVj2o,13267
17
+ provider/payments/blockchain_service.py,sha256=0iHfvviQMSFfRSSqK0Y4gdbqPa0mrxtJIN4nSG9D0D0,5171
18
+ provider/payments/monitor.py,sha256=kEuD_-amaNJY6Hkrx5hvWPqNtaslnruq3_EynB4bdUY,2804
19
+ provider/payments/stream_map.py,sha256=qk6Y8hS72DplAifZ0ZMWPHBAyc_3IWIQyWUBuCU3_To,1191
16
20
  provider/security/ethereum.py,sha256=EwPZj4JR8OEpto6LhKjuuT3Z9pBX6P7-UQaqJtqFkYQ,1242
17
21
  provider/security/faucet.py,sha256=O2DgP3bIrRUm9tdLCdgnda9em0rPyeW42sWhO1EQJaA,5363
18
- provider/service.py,sha256=Bc7jOemhqgz9adaWOFLbWum8HFY7fmf1Mmx50-Ygwuo,2389
22
+ provider/service.py,sha256=IIjeSM9T4r616nBRnxCUum_sgbyRusMMcja3yQd8zQI,3383
19
23
  provider/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
24
  provider/utils/ascii_art.py,sha256=ykBFsztk57GIiz1NJ-EII5UvN74iECqQL4h9VmiW6Z8,3161
21
25
  provider/utils/logging.py,sha256=VV3oTYSRT8hUejtXLuua1M6kCHmIJgPspIkzsUVhYW0,1920
22
26
  provider/utils/port_display.py,sha256=u1HWQFA2kPbsM-TnsQfL6Hr4KmjIZWZfsjoxarHpbW0,11981
23
- provider/utils/retry.py,sha256=1p12vZZkfotRDnv56jDXWG4W7jgGuZfRSXcbZC_ytTI,2935
27
+ provider/utils/pricing.py,sha256=e8obIH3yan8HsXJXoFvyh7eS1DtXJT5NZediPWhmJ0k,6113
28
+ provider/utils/retry.py,sha256=GvBjpr0DpTOgw28M2hI0yt17dpYLRwrxUUqVxWHQPtM,3148
24
29
  provider/utils/setup.py,sha256=Z5dLuBQkb5vdoQsu1HJZwXmu9NWsiBYJ7Vq9-C-_tY8,2932
25
30
  provider/vm/__init__.py,sha256=LJL504QGbqZvBbMN3G9ixMgAwvOWAKW37zUm_EiaW9M,508
26
31
  provider/vm/cloud_init.py,sha256=E5dDH7dqStRcJNDfbarBBe83-c9N63W8B5ycIrHI8eU,4627
27
32
  provider/vm/models.py,sha256=hNeXgOnXWyeSiYt07Pdks0B20cDi_VC8jV-tCxULNng,6350
28
33
  provider/vm/multipass.py,sha256=rjO3GtuS4O_wXyYXSUiDGWYtQV2LpGxm6kITrA-ghBQ,617
29
- provider/vm/multipass_adapter.py,sha256=1s3PtiaRmRxqOQJU651pIgZW1kwnZtl-Tclkk341TLM,10104
34
+ provider/vm/multipass_adapter.py,sha256=BUC9thQqzKVZqpSWMI9Nbx-YMz-8OeYFj2bAFzFAjg8,10621
30
35
  provider/vm/name_mapper.py,sha256=14nKfCjJ1WkXfC4vnCYIxNGQUwcl2vcxrJYUAz4fL40,4073
31
36
  provider/vm/port_manager.py,sha256=iYSwjTjD_ziOhG8aI7juKHw1OwwRUTJQyQoRUNQvz9w,12514
32
37
  provider/vm/provider.py,sha256=A7QN89EJjcSS40_SmKeinG1Jp_NGffJaLse-XdKciAs,1164
33
38
  provider/vm/proxy_manager.py,sha256=n4NTsyz2rtrvjtf_ceKBk-g2q_mzqPwruB1q7UlQVBc,14928
34
- provider/vm/service.py,sha256=7MjJM4l3wylcBlOuADEVab6R5HfT5HInOFZuEGcoV3I,3982
35
- golem_vm_provider-0.1.37.dist-info/METADATA,sha256=7GEwvwqNmRygjl7RYSrja4Zlx8U9TJszd3XUvLAycF8,10943
36
- golem_vm_provider-0.1.37.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
37
- golem_vm_provider-0.1.37.dist-info/entry_points.txt,sha256=iFUajRxfViYkFcPMPv2EIjYm-BLOp86LJj_IjCwwZUw,74
38
- golem_vm_provider-0.1.37.dist-info/RECORD,,
39
+ provider/vm/service.py,sha256=Ki4SGNIZUq3XmaPMwAOoNzdZzKQsmFXid374wgjFPes,4636
40
+ golem_vm_provider-0.1.39.dist-info/METADATA,sha256=GiVapYP4oE2as0468l_t3Fp12Y_s_mOR1a9UlDsrETY,12580
41
+ golem_vm_provider-0.1.39.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
42
+ golem_vm_provider-0.1.39.dist-info/entry_points.txt,sha256=iFUajRxfViYkFcPMPv2EIjYm-BLOp86LJj_IjCwwZUw,74
43
+ golem_vm_provider-0.1.39.dist-info/RECORD,,
provider/api/models.py CHANGED
@@ -17,6 +17,10 @@ class CreateVMRequest(BaseModel):
17
17
  image: str = Field(default="24.04") # Ubuntu 24.04 LTS
18
18
  ssh_key: str = Field(..., pattern="^(ssh-rsa|ssh-ed25519) ",
19
19
  description="SSH public key for VM access")
20
+ stream_id: Optional[int] = Field(
21
+ default=None,
22
+ description="On-chain StreamPayment stream id used to fund this VM"
23
+ )
20
24
 
21
25
  @field_validator("name")
22
26
  def validate_name(cls, v: str) -> str:
@@ -106,3 +110,9 @@ class ProviderStatusResponse(BaseModel):
106
110
  resources: Dict[str, int]
107
111
  vm_count: int
108
112
  max_vms: int
113
+
114
+
115
+ class ProviderInfoResponse(BaseModel):
116
+ provider_id: str
117
+ stream_payment_address: str
118
+ glm_token_address: str
provider/api/routes.py CHANGED
@@ -11,7 +11,8 @@ from ..container import Container
11
11
  from ..utils.logging import setup_logger
12
12
  from ..utils.ascii_art import vm_creation_animation, vm_status_change
13
13
  from ..vm.models import VMInfo, VMAccessInfo, VMConfig, VMResources, VMNotFoundError
14
- from .models import CreateVMRequest
14
+ from .models import CreateVMRequest, ProviderInfoResponse
15
+ from ..payments.blockchain_service import StreamPaymentReader
15
16
  from ..vm.service import VMService
16
17
  from ..vm.multipass_adapter import MultipassError
17
18
 
@@ -31,6 +32,15 @@ async def create_vm(
31
32
  logger.info(f"📥 Received VM creation request for '{request.name}'")
32
33
 
33
34
  resources = request.resources or VMResources()
35
+
36
+ # If payments are enabled, require a valid stream before starting
37
+ if settings["STREAM_PAYMENT_ADDRESS"] and settings["STREAM_PAYMENT_ADDRESS"] != "0x0000000000000000000000000000000000000000":
38
+ if request.stream_id is None:
39
+ raise HTTPException(status_code=400, detail="stream_id required when payments are enabled")
40
+ reader = StreamPaymentReader(settings["POLYGON_RPC_URL"], settings["STREAM_PAYMENT_ADDRESS"])
41
+ ok, reason = reader.verify_stream(int(request.stream_id), settings["PROVIDER_ID"])
42
+ if not ok:
43
+ raise HTTPException(status_code=400, detail=f"invalid stream: {reason}")
34
44
 
35
45
  # Create VM config
36
46
  config = VMConfig(
@@ -46,6 +56,9 @@ async def create_vm(
46
56
  except MultipassError as e:
47
57
  logger.error(f"Failed to create VM: {e}")
48
58
  raise HTTPException(status_code=500, detail=str(e))
59
+ except HTTPException:
60
+ # Propagate explicit HTTP errors (e.g., payment gating)
61
+ raise
49
62
  except Exception as e:
50
63
  logger.error(f"An unexpected error occurred: {e}")
51
64
  raise HTTPException(status_code=500, detail="An unexpected error occurred")
@@ -168,3 +181,11 @@ async def delete_vm(
168
181
  except Exception as e:
169
182
  logger.error(f"An unexpected error occurred: {e}")
170
183
  raise HTTPException(status_code=500, detail="An unexpected error occurred")
184
+ @router.get("/provider/info", response_model=ProviderInfoResponse)
185
+ @inject
186
+ async def provider_info(settings: Settings = Depends(Provide[Container.config])) -> ProviderInfoResponse:
187
+ return ProviderInfoResponse(
188
+ provider_id=settings["PROVIDER_ID"],
189
+ stream_payment_address=settings["STREAM_PAYMENT_ADDRESS"],
190
+ glm_token_address=settings["GLM_TOKEN_ADDRESS"],
191
+ )
provider/config.py CHANGED
@@ -39,7 +39,8 @@ def ensure_config() -> None:
39
39
  print("Using default settings – run with --help to customize")
40
40
 
41
41
 
42
- ensure_config()
42
+ if not os.environ.get("GOLEM_PROVIDER_SKIP_BOOTSTRAP") and not os.environ.get("PYTEST_CURRENT_TEST"):
43
+ ensure_config()
43
44
 
44
45
 
45
46
  class Settings(BaseSettings):
@@ -125,12 +126,53 @@ class Settings(BaseSettings):
125
126
  # Discovery Service Settings
126
127
  DISCOVERY_URL: str = "http://195.201.39.101:9001"
127
128
  ADVERTISER_TYPE: str = "golem_base" # or "discovery_server"
129
+ # Deprecated: use platform-specific intervals below
128
130
  ADVERTISEMENT_INTERVAL: int = 240 # seconds
131
+ DISCOVERY_ADVERTISEMENT_INTERVAL: int = 240 # seconds
132
+ GOLEM_BASE_ADVERTISEMENT_INTERVAL: int = 3600 # seconds (on-chain cost, keep higher)
129
133
 
130
134
  # Golem Base Settings
131
135
  GOLEM_BASE_RPC_URL: str = "https://ethwarsaw.holesky.golemdb.io/rpc"
132
136
  GOLEM_BASE_WS_URL: str = "wss://ethwarsaw.holesky.golemdb.io/rpc/ws"
133
137
 
138
+ # Polygon / Payments
139
+ POLYGON_RPC_URL: str = Field(
140
+ default="https://polygon-rpc.com",
141
+ description="Polygon PoS RPC URL for GLM payments"
142
+ )
143
+ STREAM_PAYMENT_ADDRESS: str = Field(
144
+ default="0x0000000000000000000000000000000000000000",
145
+ description="Deployed StreamPayment contract address"
146
+ )
147
+ GLM_TOKEN_ADDRESS: str = Field(
148
+ default="0x0000000000000000000000000000000000000000",
149
+ description="GLM ERC20 token address on target network"
150
+ )
151
+ STREAM_MIN_REMAINING_SECONDS: int = Field(
152
+ default=3600,
153
+ description="Minimum remaining seconds required to keep a VM running"
154
+ )
155
+ STREAM_MONITOR_ENABLED: bool = Field(
156
+ default=False,
157
+ description="Enable background monitor to stop VMs when runway < threshold"
158
+ )
159
+ STREAM_WITHDRAW_ENABLED: bool = Field(
160
+ default=False,
161
+ description="Enable background withdrawals for active streams"
162
+ )
163
+ STREAM_MONITOR_INTERVAL_SECONDS: int = Field(
164
+ default=60,
165
+ description="How frequently to check stream runway"
166
+ )
167
+ STREAM_WITHDRAW_INTERVAL_SECONDS: int = Field(
168
+ default=1800,
169
+ description="How frequently to attempt withdrawals"
170
+ )
171
+ STREAM_MIN_WITHDRAW_WEI: int = Field(
172
+ default=0,
173
+ description="Min withdrawable amount (wei) before triggering withdraw"
174
+ )
175
+
134
176
  # VM Settings
135
177
  MAX_VMS: int = 10
136
178
  DEFAULT_VM_IMAGE: str = "ubuntu:24.04"
@@ -274,6 +316,14 @@ class Settings(BaseSettings):
274
316
  # Rate Limiting
275
317
  RATE_LIMIT_PER_MINUTE: int = 100
276
318
 
319
+ # Retry/Timeout Settings (for long-running external calls)
320
+ RETRY_ATTEMPTS: int = 5
321
+ RETRY_DELAY_SECONDS: float = 2.0
322
+ RETRY_BACKOFF: float = 2.0
323
+ CREATE_VM_MAX_RETRIES: int = 15
324
+ CREATE_VM_RETRY_DELAY_SECONDS: float = 5.0
325
+ LAUNCH_TIMEOUT_SECONDS: int = 300
326
+
277
327
  # Multipass Settings
278
328
  MULTIPASS_BINARY_PATH: str = Field(
279
329
  default="",
@@ -467,6 +517,25 @@ class Settings(BaseSettings):
467
517
  logger.info(f"Using manually provided IP: {v}")
468
518
  return v
469
519
 
520
+ # Pricing Settings (configured in USD; auto-converted to GLM)
521
+ # Per-month prices per unit
522
+ PRICE_USD_PER_CORE_MONTH: float = Field(default=5.0, ge=0)
523
+ PRICE_USD_PER_GB_RAM_MONTH: float = Field(default=2.0, ge=0)
524
+ PRICE_USD_PER_GB_STORAGE_MONTH: float = Field(default=0.1, ge=0)
525
+
526
+ # Auto-updated GLM-denominated prices (derived from USD via CoinGecko)
527
+ PRICE_GLM_PER_CORE_MONTH: float = Field(default=0.0, ge=0)
528
+ PRICE_GLM_PER_GB_RAM_MONTH: float = Field(default=0.0, ge=0)
529
+ PRICE_GLM_PER_GB_STORAGE_MONTH: float = Field(default=0.0, ge=0)
530
+
531
+ # CoinGecko integration
532
+ COINGECKO_API_URL: str = "https://api.coingecko.com/api/v3"
533
+ COINGECKO_IDS: str = "golem,golem-network-tokens" # try both, first wins
534
+ PRICING_UPDATE_ENABLED: bool = True
535
+ PRICING_UPDATE_MIN_DELTA_PERCENT: float = Field(default=1.0, ge=0.0)
536
+ PRICING_UPDATE_INTERVAL_DISCOVERY: int = 900 # 15 minutes
537
+ PRICING_UPDATE_INTERVAL_GOLEM_BASE: int = 14400 # 4 hours
538
+
470
539
  class Config:
471
540
  env_prefix = "GOLEM_PROVIDER_"
472
541
  case_sensitive = True
provider/container.py CHANGED
@@ -6,6 +6,7 @@ from .config import settings
6
6
  from .discovery.resource_tracker import ResourceTracker
7
7
  from .discovery.golem_base_advertiser import GolemBaseAdvertiser
8
8
  from .discovery.advertiser import DiscoveryServerAdvertiser
9
+ from .discovery.multi_advertiser import MultiAdvertiser
9
10
  from .discovery.service import AdvertisementService
10
11
  from .service import ProviderService
11
12
  from .vm.multipass_adapter import MultipassAdapter
@@ -13,6 +14,9 @@ from .vm.service import VMService
13
14
  from .vm.name_mapper import VMNameMapper
14
15
  from .vm.port_manager import PortManager
15
16
  from .vm.proxy_manager import PythonProxyManager
17
+ from .payments.stream_map import StreamMap
18
+ from .payments.blockchain_service import StreamPaymentReader, StreamPaymentClient, StreamPaymentConfig as _SPC
19
+ from .payments.monitor import StreamMonitor
16
20
 
17
21
 
18
22
  class Container(containers.DeclarativeContainer):
@@ -32,6 +36,10 @@ class Container(containers.DeclarativeContainer):
32
36
  DiscoveryServerAdvertiser,
33
37
  resource_tracker=resource_tracker,
34
38
  ),
39
+ both=providers.Singleton(
40
+ MultiAdvertiser,
41
+ resource_tracker=resource_tracker,
42
+ ),
35
43
  )
36
44
 
37
45
  advertisement_service = providers.Singleton(
@@ -44,6 +52,11 @@ class Container(containers.DeclarativeContainer):
44
52
  db_path=Path(settings.VM_DATA_DIR) / "vm_names.json",
45
53
  )
46
54
 
55
+ stream_map = providers.Singleton(
56
+ StreamMap,
57
+ storage_path=Path(settings.VM_DATA_DIR) / "streams.json",
58
+ )
59
+
47
60
  port_manager = providers.Singleton(
48
61
  PortManager,
49
62
  start_port=config.PORT_RANGE_START,
@@ -76,6 +89,31 @@ class Container(containers.DeclarativeContainer):
76
89
  name_mapper=vm_name_mapper,
77
90
  )
78
91
 
92
+ # Payments
93
+ stream_reader = providers.Factory(
94
+ StreamPaymentReader,
95
+ rpc_url=config.POLYGON_RPC_URL,
96
+ contract_address=config.STREAM_PAYMENT_ADDRESS,
97
+ )
98
+ stream_client = providers.Factory(
99
+ StreamPaymentClient,
100
+ cfg=providers.Callable(
101
+ lambda rpc, addr, pk: _SPC(rpc_url=rpc, contract_address=addr, private_key=pk),
102
+ config.POLYGON_RPC_URL,
103
+ config.STREAM_PAYMENT_ADDRESS,
104
+ config.ETHEREUM_PRIVATE_KEY,
105
+ ),
106
+ )
107
+
108
+ stream_monitor = providers.Singleton(
109
+ StreamMonitor,
110
+ stream_map=stream_map,
111
+ vm_service=vm_service,
112
+ reader=stream_reader,
113
+ client=stream_client,
114
+ settings=config,
115
+ )
116
+
79
117
  provider_service = providers.Singleton(
80
118
  ProviderService,
81
119
  vm_service=vm_service,
@@ -65,7 +65,7 @@ class DiscoveryServerAdvertiser(Advertiser):
65
65
  try:
66
66
  while not self._stop_event.is_set():
67
67
  await self.post_advertisement()
68
- await asyncio.sleep(settings.ADVERTISEMENT_INTERVAL)
68
+ await asyncio.sleep(settings.DISCOVERY_ADVERTISEMENT_INTERVAL)
69
69
  finally:
70
70
  await self.stop()
71
71
 
@@ -76,7 +76,12 @@ class DiscoveryServerAdvertiser(Advertiser):
76
76
  await self.session.close()
77
77
  self.session = None
78
78
 
79
- @async_retry(retries=5, delay=1.0, backoff=2.0, exceptions=(aiohttp.ClientError, asyncio.TimeoutError))
79
+ @async_retry(
80
+ retries=settings.RETRY_ATTEMPTS,
81
+ delay=settings.RETRY_DELAY_SECONDS,
82
+ backoff=settings.RETRY_BACKOFF,
83
+ exceptions=(aiohttp.ClientError, asyncio.TimeoutError),
84
+ )
80
85
  async def _check_discovery_health(self):
81
86
  """Check discovery service health with retries."""
82
87
  if not self.session:
@@ -86,7 +91,12 @@ class DiscoveryServerAdvertiser(Advertiser):
86
91
  if not response.ok:
87
92
  raise Exception(f"Discovery service health check failed: {response.status}")
88
93
 
89
- @async_retry(retries=3, delay=1.0, backoff=2.0, exceptions=(aiohttp.ClientError, asyncio.TimeoutError))
94
+ @async_retry(
95
+ retries=settings.RETRY_ATTEMPTS,
96
+ delay=settings.RETRY_DELAY_SECONDS,
97
+ backoff=settings.RETRY_BACKOFF,
98
+ exceptions=(aiohttp.ClientError, asyncio.TimeoutError),
99
+ )
90
100
  async def post_advertisement(self):
91
101
  """Post resource advertisement to discovery service."""
92
102
  if not self.session:
@@ -115,7 +125,15 @@ class DiscoveryServerAdvertiser(Advertiser):
115
125
  json={
116
126
  "ip_address": ip_address,
117
127
  "country": settings.PROVIDER_COUNTRY,
118
- "resources": resources
128
+ "resources": resources,
129
+ "pricing": {
130
+ "usd_per_core_month": settings.PRICE_USD_PER_CORE_MONTH,
131
+ "usd_per_gb_ram_month": settings.PRICE_USD_PER_GB_RAM_MONTH,
132
+ "usd_per_gb_storage_month": settings.PRICE_USD_PER_GB_STORAGE_MONTH,
133
+ "glm_per_core_month": settings.PRICE_GLM_PER_CORE_MONTH,
134
+ "glm_per_gb_ram_month": settings.PRICE_GLM_PER_GB_RAM_MONTH,
135
+ "glm_per_gb_storage_month": settings.PRICE_GLM_PER_GB_STORAGE_MONTH,
136
+ }
119
137
  },
120
138
  timeout=aiohttp.ClientTimeout(total=5)
121
139
  ) as response:
@@ -132,7 +150,12 @@ class DiscoveryServerAdvertiser(Advertiser):
132
150
  logger.error("Advertisement request timed out")
133
151
  raise
134
152
 
135
- @async_retry(retries=3, delay=1.0, backoff=2.0, exceptions=(aiohttp.ClientError, asyncio.TimeoutError))
153
+ @async_retry(
154
+ retries=settings.RETRY_ATTEMPTS,
155
+ delay=settings.RETRY_DELAY_SECONDS,
156
+ backoff=settings.RETRY_BACKOFF,
157
+ exceptions=(aiohttp.ClientError, asyncio.TimeoutError),
158
+ )
136
159
  async def _get_public_ip(self) -> str:
137
160
  """Get public IP address with retries."""
138
161
  if not self.session:
@@ -36,7 +36,7 @@ class GolemBaseAdvertiser(Advertiser):
36
36
  try:
37
37
  while not self._stop_event.is_set():
38
38
  await self.post_advertisement()
39
- await asyncio.sleep(settings.ADVERTISEMENT_INTERVAL)
39
+ await asyncio.sleep(settings.GOLEM_BASE_ADVERTISEMENT_INTERVAL)
40
40
  finally:
41
41
  await self.stop()
42
42
 
@@ -70,11 +70,18 @@ class GolemBaseAdvertiser(Advertiser):
70
70
  Annotation(key="golem_ip_address", value=ip_address),
71
71
  Annotation(key="golem_country", value=settings.PROVIDER_COUNTRY),
72
72
  Annotation(key="golem_provider_name", value=settings.PROVIDER_NAME),
73
+ Annotation(key="golem_price_currency", value="USD/GLM"),
73
74
  ]
74
75
  numeric_annotations = [
75
76
  Annotation(key="golem_cpu", value=resources["cpu"]),
76
77
  Annotation(key="golem_memory", value=resources["memory"]),
77
78
  Annotation(key="golem_storage", value=resources["storage"]),
79
+ Annotation(key="golem_price_usd_core_month", value=float(settings.PRICE_USD_PER_CORE_MONTH)),
80
+ Annotation(key="golem_price_usd_ram_gb_month", value=float(settings.PRICE_USD_PER_GB_RAM_MONTH)),
81
+ Annotation(key="golem_price_usd_storage_gb_month", value=float(settings.PRICE_USD_PER_GB_STORAGE_MONTH)),
82
+ Annotation(key="golem_price_glm_core_month", value=float(settings.PRICE_GLM_PER_CORE_MONTH)),
83
+ Annotation(key="golem_price_glm_ram_gb_month", value=float(settings.PRICE_GLM_PER_GB_RAM_MONTH)),
84
+ Annotation(key="golem_price_glm_storage_gb_month", value=float(settings.PRICE_GLM_PER_GB_STORAGE_MONTH)),
78
85
  ]
79
86
 
80
87
  if len(existing_keys) > 1:
@@ -0,0 +1,28 @@
1
+ import asyncio
2
+ from typing import Optional
3
+
4
+ from .advertiser import Advertiser
5
+ from .golem_base_advertiser import GolemBaseAdvertiser
6
+ from .advertiser import DiscoveryServerAdvertiser
7
+ from .resource_tracker import ResourceTracker
8
+
9
+
10
+ class MultiAdvertiser(Advertiser):
11
+ """Advertise to both Golem Base and the Discovery Server."""
12
+
13
+ def __init__(self, resource_tracker: ResourceTracker):
14
+ self.golem = GolemBaseAdvertiser(resource_tracker)
15
+ self.discovery = DiscoveryServerAdvertiser(resource_tracker)
16
+
17
+ async def initialize(self):
18
+ await asyncio.gather(self.golem.initialize(), self.discovery.initialize())
19
+
20
+ async def start_loop(self):
21
+ await asyncio.gather(self.golem.start_loop(), self.discovery.start_loop())
22
+
23
+ async def stop(self):
24
+ await asyncio.gather(self.golem.stop(), self.discovery.stop())
25
+
26
+ async def post_advertisement(self):
27
+ await asyncio.gather(self.golem.post_advertisement(), self.discovery.post_advertisement())
28
+
@@ -21,4 +21,11 @@ class AdvertisementService:
21
21
  if self._task:
22
22
  self._task.cancel()
23
23
  await self._task
24
- await self.advertiser.stop()
24
+ await self.advertiser.stop()
25
+
26
+ async def trigger_update(self):
27
+ """Trigger an immediate advertisement update."""
28
+ try:
29
+ await self.advertiser.post_advertisement()
30
+ except Exception:
31
+ pass
provider/main.py CHANGED
@@ -121,6 +121,8 @@ except ImportError:
121
121
  import importlib_metadata as metadata
122
122
 
123
123
  cli = typer.Typer()
124
+ pricing_app = typer.Typer(help="Configure USD pricing; auto-converts to GLM.")
125
+ cli.add_typer(pricing_app, name="pricing")
124
126
 
125
127
  def print_version(ctx: typer.Context, value: bool):
126
128
  if not value:
@@ -149,6 +151,64 @@ def dev(no_verify_port: bool = typer.Option(True, "--no-verify-port", help="Skip
149
151
  """Start the provider server in development mode."""
150
152
  run_server(dev_mode=True, no_verify_port=no_verify_port)
151
153
 
154
+ def _env_path_for(dev_mode: Optional[bool]) -> str:
155
+ from pathlib import Path
156
+ env_file = ".env.dev" if dev_mode else ".env"
157
+ return str(Path(__file__).parent.parent / env_file)
158
+
159
+ def _write_env_vars(path: str, updates: dict):
160
+ # Simple .env updater: preserves other lines, replaces/append updated keys
161
+ import re
162
+ import io
163
+ try:
164
+ with open(path, "r") as f:
165
+ lines = f.readlines()
166
+ except FileNotFoundError:
167
+ lines = []
168
+
169
+ kv = {**updates}
170
+ pattern = re.compile(r"^(?P<k>[A-Z0-9_]+)=.*$")
171
+ out = []
172
+ seen = set()
173
+ for line in lines:
174
+ m = pattern.match(line.strip())
175
+ if not m:
176
+ out.append(line)
177
+ continue
178
+ k = m.group("k")
179
+ if k in kv:
180
+ out.append(f"{k}={kv[k]}\n")
181
+ seen.add(k)
182
+ else:
183
+ out.append(line)
184
+ for k, v in kv.items():
185
+ if k not in seen:
186
+ out.append(f"{k}={v}\n")
187
+
188
+ with open(path, "w") as f:
189
+ f.writelines(out)
190
+
191
+ def _print_pricing_examples(glm_usd):
192
+ from decimal import Decimal
193
+ from .utils.pricing import calculate_monthly_cost, calculate_monthly_cost_usd
194
+ from .vm.models import VMResources
195
+ examples = [
196
+ ("Small", VMResources(cpu=1, memory=1, storage=10)),
197
+ ("Medium", VMResources(cpu=2, memory=4, storage=20)),
198
+ ("Example 2c/2g/10g", VMResources(cpu=2, memory=2, storage=10)),
199
+ ]
200
+ # Maintain legacy header for tests while adding a clearer caption
201
+ print("\nExample monthly costs with current settings:")
202
+ print("(Estimated monthly earnings with your current pricing)")
203
+ for name, res in examples:
204
+ glm = calculate_monthly_cost(res)
205
+ usd = calculate_monthly_cost_usd(res, glm_usd)
206
+ usd_str = f"${usd:.2f}" if usd is not None else "—"
207
+ glm_str = f"{glm:.4f} GLM"
208
+ print(
209
+ f"- {name} ({res.cpu}C, {res.memory}GB RAM, {res.storage}GB Disk): ~{usd_str} per month (~{glm_str})"
210
+ )
211
+
152
212
  def run_server(dev_mode: bool, no_verify_port: bool):
153
213
  """Helper to run the uvicorn server."""
154
214
  import sys
@@ -209,3 +269,74 @@ def run_server(dev_mode: bool, no_verify_port: bool):
209
269
 
210
270
  if __name__ == "__main__":
211
271
  cli()
272
+
273
+
274
+ @pricing_app.command("show")
275
+ def pricing_show():
276
+ """Show current USD and GLM per-unit monthly prices and examples."""
277
+ from decimal import Decimal
278
+ from .utils.pricing import fetch_glm_usd_price, update_glm_unit_prices_from_usd
279
+
280
+ print("Current pricing (per month):")
281
+ print(
282
+ f" - USD per unit: CPU ${settings.PRICE_USD_PER_CORE_MONTH}/core, RAM ${settings.PRICE_USD_PER_GB_RAM_MONTH}/GB, Disk ${settings.PRICE_USD_PER_GB_STORAGE_MONTH}/GB"
283
+ )
284
+ glm_usd = fetch_glm_usd_price()
285
+ if not glm_usd:
286
+ print("Error: Could not fetch GLM/USD price. Please try again later.")
287
+ raise typer.Exit(code=1)
288
+ # Coerce to Decimal for calculations if needed
289
+ from decimal import Decimal
290
+ if not isinstance(glm_usd, Decimal):
291
+ glm_usd = Decimal(str(glm_usd))
292
+ update_glm_unit_prices_from_usd(glm_usd)
293
+ print(f" - GLM price: ${glm_usd} per GLM")
294
+ print(f" - Rate: {glm_usd} USD/GLM")
295
+ print(
296
+ f" - GLM per unit: CPU {round(float(settings.PRICE_GLM_PER_CORE_MONTH), 6)} GLM/core, RAM {round(float(settings.PRICE_GLM_PER_GB_RAM_MONTH), 6)} GLM/GB, Disk {round(float(settings.PRICE_GLM_PER_GB_STORAGE_MONTH), 6)} GLM/GB"
297
+ )
298
+ _print_pricing_examples(glm_usd)
299
+
300
+
301
+ @pricing_app.command("set")
302
+ def pricing_set(
303
+ usd_per_core: float = typer.Option(
304
+ ..., "--usd-per-core", "--core-usd", help="USD per CPU core per month"
305
+ ),
306
+ usd_per_mem: float = typer.Option(
307
+ ..., "--usd-per-mem", "--ram-usd", help="USD per GB of RAM per month"
308
+ ),
309
+ usd_per_disk: float = typer.Option(
310
+ ..., "--usd-per-disk", "--usd-per-storage", "--storage-usd", help="USD per GB of disk per month"
311
+ ),
312
+ dev: bool = typer.Option(False, "--dev", help="Write to .env.dev instead of .env"),
313
+ ):
314
+ """Set USD pricing; GLM rates auto-update via CoinGecko in background."""
315
+ if usd_per_core < 0 or usd_per_mem < 0 or usd_per_disk < 0:
316
+ raise typer.BadParameter("All pricing values must be >= 0")
317
+ env_path = _env_path_for(dev)
318
+ updates = {
319
+ "GOLEM_PROVIDER_PRICE_USD_PER_CORE_MONTH": usd_per_core,
320
+ "GOLEM_PROVIDER_PRICE_USD_PER_GB_RAM_MONTH": usd_per_mem,
321
+ "GOLEM_PROVIDER_PRICE_USD_PER_GB_STORAGE_MONTH": usd_per_disk,
322
+ }
323
+ _write_env_vars(env_path, updates)
324
+ print(f"Updated pricing in {env_path}")
325
+ # Immediately reflect in current process settings as well
326
+ settings.PRICE_USD_PER_CORE_MONTH = usd_per_core
327
+ settings.PRICE_USD_PER_GB_RAM_MONTH = usd_per_mem
328
+ settings.PRICE_USD_PER_GB_STORAGE_MONTH = usd_per_disk
329
+
330
+ from .utils.pricing import fetch_glm_usd_price, update_glm_unit_prices_from_usd
331
+ glm_usd = fetch_glm_usd_price()
332
+ if glm_usd:
333
+ # Coerce to Decimal for calculations if needed
334
+ from decimal import Decimal
335
+ if not isinstance(glm_usd, Decimal):
336
+ glm_usd = Decimal(str(glm_usd))
337
+ update_glm_unit_prices_from_usd(glm_usd)
338
+ print("Recalculated GLM prices due to updated USD configuration.")
339
+ _print_pricing_examples(glm_usd)
340
+ else:
341
+ print("Warning: could not fetch GLM/USD; GLM unit prices not recalculated.")
342
+ print("Tip: run 'golem-provider pricing show' when online to verify pricing with USD examples.")
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict
5
+
6
+ from web3 import Web3
7
+ from eth_account import Account
8
+
9
+
10
+ STREAM_PAYMENT_ABI = [
11
+ {
12
+ "inputs": [
13
+ {"internalType": "address", "name": "token", "type": "address"},
14
+ {"internalType": "address", "name": "recipient", "type": "address"},
15
+ {"internalType": "uint256", "name": "deposit", "type": "uint256"},
16
+ {"internalType": "uint128", "name": "ratePerSecond", "type": "uint128"},
17
+ ],
18
+ "name": "createStream",
19
+ "outputs": [{"internalType": "uint256", "name": "streamId", "type": "uint256"}],
20
+ "stateMutability": "nonpayable",
21
+ "type": "function",
22
+ },
23
+ {
24
+ "inputs": [{"internalType": "uint256", "name": "streamId", "type": "uint256"}],
25
+ "name": "withdraw",
26
+ "outputs": [],
27
+ "stateMutability": "nonpayable",
28
+ "type": "function",
29
+ },
30
+ {
31
+ "inputs": [{"internalType": "uint256", "name": "streamId", "type": "uint256"}],
32
+ "name": "terminate",
33
+ "outputs": [],
34
+ "stateMutability": "nonpayable",
35
+ "type": "function",
36
+ },
37
+ {
38
+ "inputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
39
+ "name": "streams",
40
+ "outputs": [
41
+ {"internalType": "address", "name": "token", "type": "address"},
42
+ {"internalType": "address", "name": "sender", "type": "address"},
43
+ {"internalType": "address", "name": "recipient", "type": "address"},
44
+ {"internalType": "uint128", "name": "startTime", "type": "uint128"},
45
+ {"internalType": "uint128", "name": "stopTime", "type": "uint128"},
46
+ {"internalType": "uint128", "name": "ratePerSecond", "type": "uint128"},
47
+ {"internalType": "uint256", "name": "deposit", "type": "uint256"},
48
+ {"internalType": "uint256", "name": "withdrawn", "type": "uint256"},
49
+ {"internalType": "bool", "name": "halted", "type": "bool"}
50
+ ],
51
+ "stateMutability": "view",
52
+ "type": "function"
53
+ },
54
+ ]
55
+
56
+
57
+ @dataclass
58
+ class StreamPaymentConfig:
59
+ rpc_url: str
60
+ contract_address: str
61
+ private_key: str
62
+
63
+
64
+ class StreamPaymentClient:
65
+ def __init__(self, cfg: StreamPaymentConfig):
66
+ self.web3 = Web3(Web3.HTTPProvider(cfg.rpc_url))
67
+ self.account = Account.from_key(cfg.private_key)
68
+ self.web3.eth.default_account = self.account.address
69
+ self.contract = self.web3.eth.contract(
70
+ address=Web3.to_checksum_address(cfg.contract_address), abi=STREAM_PAYMENT_ABI
71
+ )
72
+
73
+ def _send(self, fn) -> Dict[str, Any]:
74
+ tx = fn.build_transaction(
75
+ {
76
+ "from": self.account.address,
77
+ "nonce": self.web3.eth.get_transaction_count(self.account.address),
78
+ }
79
+ )
80
+ if hasattr(self.account, "sign_transaction"):
81
+ signed = self.account.sign_transaction(tx)
82
+ tx_hash = self.web3.eth.send_raw_transaction(signed.rawTransaction)
83
+ else:
84
+ tx_hash = self.web3.eth.send_transaction(tx)
85
+ receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash)
86
+ return {"transactionHash": tx_hash.hex(), "status": receipt.status}
87
+
88
+ def withdraw(self, stream_id: int) -> str:
89
+ fn = self.contract.functions.withdraw(int(stream_id))
90
+ receipt = self._send(fn)
91
+ return receipt["transactionHash"]
92
+
93
+ class StreamPaymentReader:
94
+ def __init__(self, rpc_url: str, contract_address: str):
95
+ self.web3 = Web3(Web3.HTTPProvider(rpc_url))
96
+ self.contract = self.web3.eth.contract(
97
+ address=Web3.to_checksum_address(contract_address), abi=STREAM_PAYMENT_ABI
98
+ )
99
+
100
+ def get_stream(self, stream_id: int) -> dict:
101
+ token, sender, recipient, startTime, stopTime, ratePerSecond, deposit, withdrawn, halted = (
102
+ self.contract.functions.streams(int(stream_id)).call()
103
+ )
104
+ return {
105
+ "token": token,
106
+ "sender": sender,
107
+ "recipient": recipient,
108
+ "startTime": int(startTime),
109
+ "stopTime": int(stopTime),
110
+ "ratePerSecond": int(ratePerSecond),
111
+ "deposit": int(deposit),
112
+ "withdrawn": int(withdrawn),
113
+ "halted": bool(halted),
114
+ }
115
+
116
+ def verify_stream(self, stream_id: int, expected_recipient: str) -> tuple[bool, str]:
117
+ try:
118
+ s = self.get_stream(stream_id)
119
+ except Exception as e:
120
+ return False, f"stream lookup failed: {e}"
121
+ if s["recipient"].lower() != expected_recipient.lower():
122
+ return False, "recipient mismatch"
123
+ if s["deposit"] <= 0:
124
+ return False, "no deposit"
125
+ now = int(self.web3.eth.get_block("latest")["timestamp"])
126
+ if s["startTime"] > now:
127
+ return False, "stream not started"
128
+ if s["halted"]:
129
+ return False, "stream halted"
130
+ return True, "ok"
131
+
132
+ def terminate(self, stream_id: int) -> str:
133
+ fn = self.contract.functions.terminate(int(stream_id))
134
+ receipt = self._send(fn)
135
+ return receipt["transactionHash"]
@@ -0,0 +1,64 @@
1
+ import asyncio
2
+ from typing import Optional
3
+
4
+ from ..utils.logging import setup_logger
5
+
6
+ logger = setup_logger(__name__)
7
+
8
+
9
+ class StreamMonitor:
10
+ def __init__(self, *, stream_map, vm_service, reader, client, settings):
11
+ self.stream_map = stream_map
12
+ self.vm_service = vm_service
13
+ self.reader = reader
14
+ self.client = client
15
+ self.settings = settings
16
+ self._task: Optional[asyncio.Task] = None
17
+
18
+ def start(self):
19
+ if self.settings.STREAM_MONITOR_ENABLED or self.settings.STREAM_WITHDRAW_ENABLED:
20
+ self._task = asyncio.create_task(self._run(), name="stream-monitor")
21
+
22
+ async def stop(self):
23
+ if self._task:
24
+ self._task.cancel()
25
+ try:
26
+ await self._task
27
+ except asyncio.CancelledError:
28
+ pass
29
+
30
+ async def _run(self):
31
+ last_withdraw = 0
32
+ while True:
33
+ try:
34
+ await asyncio.sleep(self.settings.STREAM_MONITOR_INTERVAL_SECONDS)
35
+ items = await self.stream_map.all_items()
36
+ now = int(self.reader.web3.eth.get_block("latest")["timestamp"]) if items else 0
37
+ for vm_id, stream_id in items.items():
38
+ try:
39
+ s = self.reader.get_stream(stream_id)
40
+ except Exception as e:
41
+ logger.warning(f"stream {stream_id} lookup failed: {e}")
42
+ continue
43
+ # Stop VM if remaining runway < threshold
44
+ remaining = max(s["stopTime"] - now, 0)
45
+ if self.settings.STREAM_MONITOR_ENABLED and remaining < self.settings.STREAM_MIN_REMAINING_SECONDS:
46
+ logger.info(f"Stopping VM {vm_id} due to low stream runway ({remaining}s)")
47
+ try:
48
+ await self.vm_service.stop_vm(vm_id)
49
+ except Exception as e:
50
+ logger.warning(f"stop_vm failed for {vm_id}: {e}")
51
+ # Withdraw if enough vested and configured
52
+ if self.settings.STREAM_WITHDRAW_ENABLED and self.client:
53
+ vested = max(min(now, s["stopTime"]) - s["startTime"], 0) * s["ratePerSecond"]
54
+ withdrawable = max(vested - s["withdrawn"], 0)
55
+ if withdrawable >= self.settings.STREAM_MIN_WITHDRAW_WEI:
56
+ try:
57
+ self.client.withdraw(stream_id)
58
+ except Exception as e:
59
+ logger.warning(f"withdraw failed for {stream_id}: {e}")
60
+ except asyncio.CancelledError:
61
+ break
62
+ except Exception as e:
63
+ logger.error(f"stream monitor error: {e}")
64
+
@@ -0,0 +1,40 @@
1
+ import asyncio
2
+ import json
3
+ from pathlib import Path
4
+ from typing import Dict, Optional
5
+
6
+
7
+ class StreamMap:
8
+ def __init__(self, storage_path: Path):
9
+ self._path = storage_path
10
+ self._lock = asyncio.Lock()
11
+ self._data: Dict[str, int] = {}
12
+ if self._path.exists():
13
+ try:
14
+ self._data = json.loads(self._path.read_text())
15
+ except Exception:
16
+ self._data = {}
17
+
18
+ async def set(self, vm_id: str, stream_id: int) -> None:
19
+ async with self._lock:
20
+ self._data[vm_id] = int(stream_id)
21
+ self._persist()
22
+
23
+ async def get(self, vm_id: str) -> Optional[int]:
24
+ return self._data.get(vm_id)
25
+
26
+ async def remove(self, vm_id: str) -> None:
27
+ async with self._lock:
28
+ if vm_id in self._data:
29
+ del self._data[vm_id]
30
+ self._persist()
31
+
32
+ async def all_items(self) -> Dict[str, int]:
33
+ return dict(self._data)
34
+
35
+ def _persist(self) -> None:
36
+ self._path.parent.mkdir(parents=True, exist_ok=True)
37
+ tmp = self._path.with_suffix(".tmp")
38
+ tmp.write_text(json.dumps(self._data, indent=2))
39
+ tmp.replace(self._path)
40
+
provider/service.py CHANGED
@@ -4,6 +4,7 @@ from fastapi import FastAPI
4
4
  from .utils.logging import setup_logger
5
5
  from .vm.service import VMService
6
6
  from .discovery.service import AdvertisementService
7
+ from .utils.pricing import PricingAutoUpdater
7
8
 
8
9
  logger = setup_logger(__name__)
9
10
 
@@ -15,6 +16,8 @@ class ProviderService:
15
16
  self.vm_service = vm_service
16
17
  self.advertisement_service = advertisement_service
17
18
  self.port_manager = port_manager
19
+ self._pricing_updater: PricingAutoUpdater | None = None
20
+ self._stream_monitor = None
18
21
 
19
22
  async def setup(self, app: FastAPI):
20
23
  """Setup and initialize the provider components."""
@@ -35,6 +38,18 @@ class ProviderService:
35
38
  await self.port_manager.initialize()
36
39
  await self.vm_service.provider.initialize()
37
40
  await self.advertisement_service.start()
41
+ # Start pricing auto-updater; trigger re-advertise after updates
42
+ async def _on_price_updated(platform: str, glm_usd):
43
+ await self.advertisement_service.trigger_update()
44
+ self._pricing_updater = PricingAutoUpdater(on_updated_callback=_on_price_updated)
45
+ asyncio.create_task(self._pricing_updater.start())
46
+
47
+ # Start stream monitor if enabled
48
+ from .container import Container
49
+ from .config import settings as cfg
50
+ if cfg.STREAM_MONITOR_ENABLED or cfg.STREAM_WITHDRAW_ENABLED:
51
+ self._stream_monitor = app.container.stream_monitor()
52
+ self._stream_monitor.start()
38
53
 
39
54
  # Check wallet balance and request funds if needed
40
55
  faucet_client = FaucetClient(
@@ -55,6 +70,10 @@ class ProviderService:
55
70
  logger.process("🔄 Cleaning up provider...")
56
71
  await self.advertisement_service.stop()
57
72
  await self.vm_service.provider.cleanup()
73
+ if self._pricing_updater:
74
+ self._pricing_updater.stop()
75
+ if self._stream_monitor:
76
+ await self._stream_monitor.stop()
58
77
  logger.success("✨ Provider cleanup complete")
59
78
 
60
79
  def _setup_directories(self):
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from decimal import Decimal, ROUND_HALF_UP, getcontext
5
+ from typing import Optional, Tuple
6
+ import time
7
+ import requests
8
+
9
+ from ..vm.models import VMResources
10
+ from ..config import settings
11
+ from .logging import setup_logger
12
+
13
+ logger = setup_logger(__name__)
14
+
15
+ # Increase precision for financial calcs
16
+ getcontext().prec = 28
17
+
18
+
19
+ def quantize_money(value: Decimal) -> Decimal:
20
+ return value.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
21
+
22
+
23
+ def _coingecko_simple_price(ids: str) -> Optional[Decimal]:
24
+ base = settings.COINGECKO_API_URL.rstrip("/")
25
+ url = f"{base}/simple/price"
26
+ try:
27
+ resp = requests.get(url, params={"ids": ids, "vs_currencies": "usd"}, timeout=10)
28
+ resp.raise_for_status()
29
+ data = resp.json()
30
+ # Try ids in order and return the first available
31
+ for _id in ids.split(","):
32
+ _id = _id.strip()
33
+ if _id and _id in data and "usd" in data[_id]:
34
+ usd = Decimal(str(data[_id]["usd"]))
35
+ if usd > 0:
36
+ return usd
37
+ except Exception as e:
38
+ logger.warning(f"CoinGecko price fetch failed: {e}")
39
+ return None
40
+
41
+
42
+ def fetch_glm_usd_price() -> Optional[Decimal]:
43
+ """Fetch the current GLM price in USD from CoinGecko.
44
+
45
+ Tries multiple IDs to hedge against slug changes.
46
+ """
47
+ return _coingecko_simple_price(settings.COINGECKO_IDS)
48
+
49
+
50
+ def usd_to_glm(usd_amount: Decimal, glm_usd: Decimal) -> Decimal:
51
+ if glm_usd <= 0:
52
+ raise ValueError("Invalid GLM/USD price")
53
+ return (usd_amount / glm_usd).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
54
+
55
+
56
+ def glm_to_usd(glm_amount: Decimal, glm_usd: Decimal) -> Decimal:
57
+ return (glm_amount * glm_usd).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
58
+
59
+
60
+ def calculate_monthly_cost(resources: VMResources) -> Decimal:
61
+ """Calculate monthly cost in GLM for the given resources.
62
+
63
+ Uses the GLM-denominated price-per-unit values configured in settings.
64
+ """
65
+ core_price = Decimal(str(settings.PRICE_GLM_PER_CORE_MONTH))
66
+ ram_price = Decimal(str(settings.PRICE_GLM_PER_GB_RAM_MONTH))
67
+ storage_price = Decimal(str(settings.PRICE_GLM_PER_GB_STORAGE_MONTH))
68
+
69
+ total = (
70
+ core_price * Decimal(resources.cpu) +
71
+ ram_price * Decimal(resources.memory) +
72
+ storage_price * Decimal(resources.storage)
73
+ )
74
+ return total.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
75
+
76
+
77
+ # Note: function to derive per-unit prices from a total was intentionally not added.
78
+
79
+
80
+ def calculate_monthly_cost_usd(resources: VMResources, glm_usd: Decimal) -> Optional[Decimal]:
81
+ cost_glm = calculate_monthly_cost(resources)
82
+ try:
83
+ return glm_to_usd(cost_glm, glm_usd)
84
+ except Exception:
85
+ return None
86
+
87
+
88
+ def update_glm_unit_prices_from_usd(glm_usd: Decimal) -> Tuple[Decimal, Decimal, Decimal]:
89
+ """Recompute GLM per-unit monthly prices using current USD config and a GLM/USD rate.
90
+
91
+ Returns a tuple of (core_glm, ram_glm, storage_glm).
92
+ """
93
+ core_usd = Decimal(str(settings.PRICE_USD_PER_CORE_MONTH))
94
+ ram_usd = Decimal(str(settings.PRICE_USD_PER_GB_RAM_MONTH))
95
+ storage_usd = Decimal(str(settings.PRICE_USD_PER_GB_STORAGE_MONTH))
96
+
97
+ core_glm = usd_to_glm(core_usd, glm_usd)
98
+ ram_glm = usd_to_glm(ram_usd, glm_usd)
99
+ storage_glm = usd_to_glm(storage_usd, glm_usd)
100
+
101
+ # Persist on settings instance (in-memory)
102
+ settings.PRICE_GLM_PER_CORE_MONTH = float(core_glm)
103
+ settings.PRICE_GLM_PER_GB_RAM_MONTH = float(ram_glm)
104
+ settings.PRICE_GLM_PER_GB_STORAGE_MONTH = float(storage_glm)
105
+
106
+ logger.info(
107
+ f"Updated GLM prices from USD @ {glm_usd} USD/GLM: core={core_glm}, ram={ram_glm}, storage={storage_glm}"
108
+ )
109
+ return core_glm, ram_glm, storage_glm
110
+
111
+
112
+ class PricingAutoUpdater:
113
+ """Background updater for pricing based on CoinGecko.
114
+
115
+ It refreshes GLM-per-unit prices from USD config and can trigger callbacks (e.g., re-advertise).
116
+ """
117
+
118
+ def __init__(self, on_updated_callback=None):
119
+ self._stop = False
120
+ self._on_updated = on_updated_callback
121
+ self._last_price: Optional[Decimal] = None
122
+
123
+ async def start(self):
124
+ if not settings.PRICING_UPDATE_ENABLED:
125
+ return
126
+
127
+ # Choose update interval based on platform to avoid excessive on-chain updates
128
+ interval = (
129
+ settings.PRICING_UPDATE_INTERVAL_GOLEM_BASE
130
+ if getattr(settings, "ADVERTISER_TYPE", "discovery_server") == "golem_base"
131
+ else settings.PRICING_UPDATE_INTERVAL_DISCOVERY
132
+ )
133
+ await self._run_loop(interval)
134
+
135
+ def stop(self):
136
+ self._stop = True
137
+
138
+ async def _run_loop(self, interval_discovery: int):
139
+ import asyncio
140
+
141
+ while not self._stop:
142
+ try:
143
+ glm_usd = fetch_glm_usd_price()
144
+ if glm_usd:
145
+ changed = self._should_update(glm_usd)
146
+ if changed:
147
+ update_glm_unit_prices_from_usd(glm_usd)
148
+ if callable(self._on_updated):
149
+ # Inform callback which advertising platform is active
150
+ platform = getattr(settings, "ADVERTISER_TYPE", "discovery_server")
151
+ await self._on_updated(platform=platform, glm_usd=glm_usd)
152
+ else:
153
+ logger.warning("Skipping pricing update; failed to fetch GLM price")
154
+ except Exception as e:
155
+ logger.error(f"Pricing update error: {e}")
156
+
157
+ await asyncio.sleep(interval_discovery)
158
+
159
+ def _should_update(self, new_price: Decimal) -> bool:
160
+ if self._last_price is None:
161
+ self._last_price = new_price
162
+ return True
163
+ old = self._last_price
164
+ if old == 0:
165
+ self._last_price = new_price
166
+ return True
167
+ delta = abs((new_price - old) / old) * Decimal("100")
168
+ if delta >= Decimal(str(settings.PRICING_UPDATE_MIN_DELTA_PERCENT)):
169
+ self._last_price = new_price
170
+ return True
171
+ return False
provider/utils/retry.py CHANGED
@@ -7,6 +7,11 @@ logger = logging.getLogger(__name__)
7
7
 
8
8
  T = TypeVar('T')
9
9
 
10
+
11
+ class NonRetryableError(Exception):
12
+ """Base class for errors that should not be retried by retry decorators."""
13
+ pass
14
+
10
15
  def async_retry(
11
16
  retries: int = 3,
12
17
  delay: float = 1.0,
@@ -68,7 +73,8 @@ def async_retry_unless_not_found(
68
73
  try:
69
74
  return await func(*args, **kwargs)
70
75
  except exceptions as e:
71
- if isinstance(e, VMNotFoundError):
76
+ # Skip retries for known non-retryable cases
77
+ if isinstance(e, (VMNotFoundError, NonRetryableError)):
72
78
  raise e
73
79
 
74
80
  last_exception = e
@@ -4,7 +4,7 @@ import subprocess
4
4
  from pathlib import Path
5
5
  import asyncio
6
6
  from typing import Dict, List, Optional
7
- from ..utils.retry import async_retry_unless_not_found
7
+ from ..utils.retry import async_retry_unless_not_found, NonRetryableError
8
8
 
9
9
  from ..config import settings
10
10
  from ..utils.logging import setup_logger
@@ -19,6 +19,11 @@ class MultipassError(VMError):
19
19
  pass
20
20
 
21
21
 
22
+ class NonRetryableMultipassError(MultipassError, NonRetryableError):
23
+ """Multipass error that should not be retried (e.g., parse/validation errors)."""
24
+ pass
25
+
26
+
22
27
  class MultipassAdapter(VMProvider):
23
28
  """Manages VMs using Multipass."""
24
29
 
@@ -35,7 +40,7 @@ class MultipassAdapter(VMProvider):
35
40
 
36
41
  # We add a timeout to the launch command to prevent it from hanging indefinitely
37
42
  # e.g. during image download. 300 seconds = 5 minutes.
38
- timeout = 300 if args[0] == 'launch' else None
43
+ timeout = settings.LAUNCH_TIMEOUT_SECONDS if args[0] == 'launch' else None
39
44
 
40
45
  try:
41
46
  return await asyncio.to_thread(
@@ -53,7 +58,11 @@ class MultipassAdapter(VMProvider):
53
58
  stderr = e.stderr if should_capture and e.stderr else "No stderr captured. See provider logs for command output."
54
59
  raise MultipassError(f"Multipass command '{' '.join(args)}' timed out after {timeout} seconds. Stderr: {stderr}")
55
60
 
56
- @async_retry_unless_not_found(retries=5, delay=2.0)
61
+ @async_retry_unless_not_found(
62
+ retries=settings.RETRY_ATTEMPTS,
63
+ delay=settings.RETRY_DELAY_SECONDS,
64
+ backoff=settings.RETRY_BACKOFF,
65
+ )
57
66
  async def _get_vm_info(self, vm_id: str) -> Dict:
58
67
  """Get detailed information about a VM."""
59
68
  try:
@@ -70,7 +79,10 @@ class MultipassAdapter(VMProvider):
70
79
  raise VMNotFoundError(f"VM {vm_id} not found in multipass") from e
71
80
  raise
72
81
  except (json.JSONDecodeError, KeyError) as e:
73
- raise MultipassError(f"Failed to parse VM info or essential fields are missing: {e}")
82
+ # Parsing/validation issues are not transient; do not waste time retrying
83
+ raise NonRetryableMultipassError(
84
+ f"Failed to parse VM info or essential fields are missing: {e}"
85
+ )
74
86
 
75
87
  async def initialize(self) -> None:
76
88
  """Initialize the VM provider."""
@@ -100,8 +112,8 @@ class MultipassAdapter(VMProvider):
100
112
  logger.info(f"VM {multipass_name} launched, waiting for it to be ready...")
101
113
 
102
114
  ip_address = None
103
- max_retries = 15
104
- retry_delay = 5 # seconds
115
+ max_retries = settings.CREATE_VM_MAX_RETRIES
116
+ retry_delay = settings.CREATE_VM_RETRY_DELAY_SECONDS # seconds
105
117
  for attempt in range(max_retries):
106
118
  try:
107
119
  info = await self._get_vm_info(multipass_name)
@@ -218,4 +230,4 @@ class MultipassAdapter(VMProvider):
218
230
 
219
231
  async def cleanup(self) -> None:
220
232
  """Cleanup resources used by the provider."""
221
- pass
233
+ pass
provider/vm/service.py CHANGED
@@ -19,10 +19,12 @@ class VMService:
19
19
  provider: VMProvider,
20
20
  resource_tracker: ResourceTracker,
21
21
  name_mapper: VMNameMapper,
22
+ blockchain_client: object | None = None,
22
23
  ):
23
24
  self.provider = provider
24
25
  self.resource_tracker = resource_tracker
25
26
  self.name_mapper = name_mapper
27
+ self.blockchain_client = blockchain_client
26
28
 
27
29
  async def create_vm(self, config: VMConfig) -> VMInfo:
28
30
  """Create a new VM."""
@@ -59,6 +61,13 @@ class VMService:
59
61
  vm_info = await self.provider.get_vm_status(multipass_name)
60
62
  await self.provider.delete_vm(multipass_name)
61
63
  await self.resource_tracker.deallocate(vm_info.resources, vm_id)
64
+ # Optional: best-effort on-chain termination if we have a mapping
65
+ try:
66
+ if self.blockchain_client:
67
+ # In future: look up stream id associated to this vm_id
68
+ pass
69
+ except Exception:
70
+ pass
62
71
  except VMNotFoundError:
63
72
  logger.warning(f"VM {multipass_name} not found on provider, cleaning up resources")
64
73
  # If the VM is not found, we still need to deallocate the resources we have tracked for it
@@ -74,7 +83,15 @@ class VMService:
74
83
  multipass_name = await self.name_mapper.get_multipass_name(vm_id)
75
84
  if not multipass_name:
76
85
  raise VMNotFoundError(f"VM {vm_id} not found")
77
- return await self.provider.stop_vm(multipass_name)
86
+ vm = await self.provider.stop_vm(multipass_name)
87
+ # Optional: best-effort withdraw for active stream
88
+ try:
89
+ if self.blockchain_client:
90
+ # In future: look up stream id associated to this vm_id
91
+ pass
92
+ except Exception:
93
+ pass
94
+ return vm
78
95
 
79
96
  async def list_vms(self) -> List[VMInfo]:
80
97
  """List all VMs."""
@@ -95,4 +112,4 @@ class VMService:
95
112
  await self.provider.initialize()
96
113
 
97
114
  async def shutdown(self):
98
- await self.provider.cleanup()
115
+ await self.provider.cleanup()