golem-vm-provider 0.1.38__tar.gz → 0.1.42__tar.gz

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.
Files changed (42) hide show
  1. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/PKG-INFO +89 -4
  2. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/README.md +86 -3
  3. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/api/models.py +10 -0
  4. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/api/routes.py +34 -1
  5. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/config.py +69 -3
  6. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/container.py +33 -0
  7. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/golem_base_advertiser.py +1 -0
  8. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/main.py +68 -9
  9. golem_vm_provider-0.1.42/provider/payments/blockchain_service.py +93 -0
  10. golem_vm_provider-0.1.42/provider/payments/monitor.py +67 -0
  11. golem_vm_provider-0.1.42/provider/payments/stream_map.py +40 -0
  12. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/security/faucet.py +17 -41
  13. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/service.py +10 -0
  14. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/service.py +19 -2
  15. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/pyproject.toml +5 -4
  16. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/__init__.py +0 -0
  17. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/api/__init__.py +0 -0
  18. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/__init__.py +0 -0
  19. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/advertiser.py +0 -0
  20. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/golem_base_utils.py +0 -0
  21. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/multi_advertiser.py +0 -0
  22. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/resource_monitor.py +0 -0
  23. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/resource_tracker.py +0 -0
  24. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/discovery/service.py +0 -0
  25. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/network/port_verifier.py +0 -0
  26. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/security/ethereum.py +0 -0
  27. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/utils/__init__.py +0 -0
  28. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/utils/ascii_art.py +0 -0
  29. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/utils/logging.py +0 -0
  30. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/utils/port_display.py +0 -0
  31. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/utils/pricing.py +0 -0
  32. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/utils/retry.py +0 -0
  33. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/utils/setup.py +0 -0
  34. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/__init__.py +0 -0
  35. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/cloud_init.py +0 -0
  36. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/models.py +0 -0
  37. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/multipass.py +0 -0
  38. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/multipass_adapter.py +0 -0
  39. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/name_mapper.py +0 -0
  40. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/port_manager.py +0 -0
  41. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/provider.py +0 -0
  42. {golem_vm_provider-0.1.38 → golem_vm_provider-0.1.42}/provider/vm/proxy_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: golem-vm-provider
3
- Version: 0.1.38
3
+ Version: 0.1.42
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
@@ -23,6 +23,8 @@ Requires-Dist: dependency-injector (>=4.41.0,<5.0.0)
23
23
  Requires-Dist: eth-account (>=0.13.6,<0.14.0)
24
24
  Requires-Dist: fastapi (>=0.103.0,<0.104.0)
25
25
  Requires-Dist: golem-base-sdk (==0.1.0)
26
+ Requires-Dist: golem-faucet (>=0.1.0,<0.2.0)
27
+ Requires-Dist: golem-streaming-abi (>=0.1.0,<0.2.0)
26
28
  Requires-Dist: httpx (>=0.23.0,<0.24.0)
27
29
  Requires-Dist: psutil (>=5.9.0,<6.0.0)
28
30
  Requires-Dist: pydantic (>=2.4.0,<3.0.0)
@@ -228,8 +230,41 @@ GOLEM_PROVIDER_PUBLIC_IP="auto"
228
230
  # Discovery Settings
229
231
  GOLEM_PROVIDER_DISCOVERY_URL="http://discovery.golem.network:9001"
230
232
  GOLEM_PROVIDER_ADVERTISEMENT_INTERVAL=240
233
+
234
+ # Network Selection
235
+ # Adds an annotation to on-chain advertisements and can be used by requestors to filter
236
+ GOLEM_PROVIDER_NETWORK="testnet" # or "mainnet"
231
237
  ```
232
238
 
239
+ ### Streaming Payments (Native ETH on L2)
240
+
241
+ Enable on‑chain stream‑gated rentals funded in native ETH. Configure (env prefix `GOLEM_PROVIDER_`):
242
+
243
+ - `POLYGON_RPC_URL` — EVM RPC URL (default points to L2: https://l2.holesky.golemdb.io/rpc)
244
+ - `STREAM_PAYMENT_ADDRESS` — StreamPayment contract address; if non‑zero, VM creation requires a valid `stream_id`
245
+ - `GLM_TOKEN_ADDRESS` — Token address; set to `0x0000000000000000000000000000000000000000` to indicate native ETH
246
+
247
+ Optional background automation (all disabled by default):
248
+
249
+ - `STREAM_MIN_REMAINING_SECONDS` — minimum remaining runway to keep a VM running (default 3600)
250
+ - `STREAM_MONITOR_ENABLED` — stop VMs when remaining runway < threshold (default false)
251
+ - `STREAM_MONITOR_INTERVAL_SECONDS` — how frequently to check runway (default 60)
252
+ - `STREAM_WITHDRAW_ENABLED` — periodically withdraw vested funds (default false)
253
+ - `STREAM_WITHDRAW_INTERVAL_SECONDS` — how often to attempt withdrawals (default 1800)
254
+ - `STREAM_MIN_WITHDRAW_WEI` — only withdraw when >= this amount (gas‑aware)
255
+
256
+ Implementation notes:
257
+
258
+ - The provider exposes `GET /api/v1/provider/info` returning `provider_id`, `stream_payment_address`, and `glm_token_address`. For ETH mode this field is the zero address (`0x000...000`). Requestors should prefer these values when opening streams.
259
+ - On successful VM creation with a valid `stream_id`, the provider persists a VM→stream mapping in `streams.json`. This enables the background monitor to stop VMs with low remaining runway and to withdraw vested funds according to configured intervals.
260
+ - When a VM is deleted, the VM→stream mapping is cleaned up.
261
+
262
+ When enabled, the provider verifies each VM creation request’s `stream_id` and refuses to start the VM if:
263
+
264
+ - stream recipient != provider’s Ethereum address
265
+ - deposit is zero, stream not started, or stream halted
266
+ - (Optional) remaining runway < `STREAM_MIN_REMAINING_SECONDS`
267
+
233
268
  ## API Reference
234
269
 
235
270
  ### Create VM
@@ -245,7 +280,8 @@ Request:
245
280
  "name": "my-webserver",
246
281
  "cpu_cores": 2,
247
282
  "memory_gb": 4,
248
- "storage_gb": 20
283
+ "storage_gb": 20,
284
+ "stream_id": 123 // required when STREAM_PAYMENT_ADDRESS is set
249
285
  }
250
286
  ```
251
287
 
@@ -273,6 +309,25 @@ Response:
273
309
  - Delete VM: `DELETE /api/v1/vms/{vm_id}`
274
310
  - Stop VM: `POST /api/v1/vms/{vm_id}/stop`
275
311
  - Get Access Info: `GET /api/v1/vms/{vm_id}/access`
312
+
313
+ ### Provider Info
314
+
315
+ ```bash
316
+ GET /api/v1/provider/info
317
+ ```
318
+
319
+ Response:
320
+
321
+ ```json
322
+ {
323
+ "provider_id": "0xProviderEthereumAddress",
324
+ "stream_payment_address": "0xStreamPayment",
325
+ "glm_token_address": "0x0000000000000000000000000000000000000000"
326
+
327
+ }
328
+ ```
329
+
330
+ Use this endpoint to discover the correct recipient for creating a GLM stream.
276
331
 
277
332
  ## Operations
278
333
 
@@ -282,10 +337,26 @@ Response:
282
337
  # To run in production mode
283
338
  poetry run golem-provider start
284
339
 
285
- # To run in development mode
286
- poetry run golem-provider dev
340
+ # To run in development mode, set the environment and optionally network
341
+ GOLEM_PROVIDER_ENVIRONMENT=development poetry run golem-provider start --network testnet
287
342
  ```
288
343
 
344
+ ### Mode vs. Network
345
+
346
+ - Development Mode (`GOLEM_PROVIDER_ENVIRONMENT=development`)
347
+ - Optimizes for local iteration: enables reload + debug logging and uses local defaults (e.g., local port check servers). May derive a local/LAN IP automatically and prefix the provider name with `DEVMODE-`.
348
+ - Does not decide which chain you target.
349
+
350
+ - Network Selection (`--network` or `GOLEM_PROVIDER_NETWORK`)
351
+ - Chooses the discovery/advertisement scope: providers advertise `golem_network=testnet|mainnet` and requestors filter accordingly.
352
+ - Pair with appropriate RPC envs (`GOLEM_PROVIDER_GOLEM_BASE_RPC_URL`, `GOLEM_PROVIDER_GOLEM_BASE_WS_URL`).
353
+ - Does not change dev ergonomics (logging, reload, or port verification behavior).
354
+
355
+ Common setups:
356
+ - Local dev on testnet: `GOLEM_PROVIDER_ENVIRONMENT=development` plus `--network testnet`.
357
+ - Staging on testnet: keep `ENVIRONMENT=production`, set `--network testnet` and testnet RPCs.
358
+ - Production on mainnet: `ENVIRONMENT=production` with `--network mainnet` and mainnet RPCs.
359
+
289
360
  The provider will:
290
361
 
291
362
  1. Verify port accessibility
@@ -297,6 +368,20 @@ The provider will:
297
368
  4. Begin resource advertisement
298
369
  5. Listen for VM requests
299
370
 
371
+ ### Faucet
372
+
373
+ - L3 (Golem Base adverts): provider auto-requests funds on startup from `FAUCET_URL` (defaults to EthWarsaw Holesky) protected by CAPTCHA at `CAPTCHA_URL/05381a2cef5e`.
374
+ - L2 (payments): Use the CLI to request native ETH:
375
+
376
+ ```bash
377
+ poetry run golem-provider wallet faucet-l2
378
+ ```
379
+
380
+ Defaults:
381
+ - L2 faucet: `https://l2.holesky.golemdb.io/faucet`
382
+ - CAPTCHA: `https://cap.gobas.me/05381a2cef5e`
383
+ - Override with env: `GOLEM_PROVIDER_L2_FAUCET_URL`, `GOLEM_PROVIDER_L2_CAPTCHA_URL`, `GOLEM_PROVIDER_L2_CAPTCHA_API_KEY`.
384
+
300
385
  ### Resource Advertisement Flow
301
386
 
302
387
  ```mermaid
@@ -185,8 +185,41 @@ GOLEM_PROVIDER_PUBLIC_IP="auto"
185
185
  # Discovery Settings
186
186
  GOLEM_PROVIDER_DISCOVERY_URL="http://discovery.golem.network:9001"
187
187
  GOLEM_PROVIDER_ADVERTISEMENT_INTERVAL=240
188
+
189
+ # Network Selection
190
+ # Adds an annotation to on-chain advertisements and can be used by requestors to filter
191
+ GOLEM_PROVIDER_NETWORK="testnet" # or "mainnet"
188
192
  ```
189
193
 
194
+ ### Streaming Payments (Native ETH on L2)
195
+
196
+ Enable on‑chain stream‑gated rentals funded in native ETH. Configure (env prefix `GOLEM_PROVIDER_`):
197
+
198
+ - `POLYGON_RPC_URL` — EVM RPC URL (default points to L2: https://l2.holesky.golemdb.io/rpc)
199
+ - `STREAM_PAYMENT_ADDRESS` — StreamPayment contract address; if non‑zero, VM creation requires a valid `stream_id`
200
+ - `GLM_TOKEN_ADDRESS` — Token address; set to `0x0000000000000000000000000000000000000000` to indicate native ETH
201
+
202
+ Optional background automation (all disabled by default):
203
+
204
+ - `STREAM_MIN_REMAINING_SECONDS` — minimum remaining runway to keep a VM running (default 3600)
205
+ - `STREAM_MONITOR_ENABLED` — stop VMs when remaining runway < threshold (default false)
206
+ - `STREAM_MONITOR_INTERVAL_SECONDS` — how frequently to check runway (default 60)
207
+ - `STREAM_WITHDRAW_ENABLED` — periodically withdraw vested funds (default false)
208
+ - `STREAM_WITHDRAW_INTERVAL_SECONDS` — how often to attempt withdrawals (default 1800)
209
+ - `STREAM_MIN_WITHDRAW_WEI` — only withdraw when >= this amount (gas‑aware)
210
+
211
+ Implementation notes:
212
+
213
+ - The provider exposes `GET /api/v1/provider/info` returning `provider_id`, `stream_payment_address`, and `glm_token_address`. For ETH mode this field is the zero address (`0x000...000`). Requestors should prefer these values when opening streams.
214
+ - On successful VM creation with a valid `stream_id`, the provider persists a VM→stream mapping in `streams.json`. This enables the background monitor to stop VMs with low remaining runway and to withdraw vested funds according to configured intervals.
215
+ - When a VM is deleted, the VM→stream mapping is cleaned up.
216
+
217
+ When enabled, the provider verifies each VM creation request’s `stream_id` and refuses to start the VM if:
218
+
219
+ - stream recipient != provider’s Ethereum address
220
+ - deposit is zero, stream not started, or stream halted
221
+ - (Optional) remaining runway < `STREAM_MIN_REMAINING_SECONDS`
222
+
190
223
  ## API Reference
191
224
 
192
225
  ### Create VM
@@ -202,7 +235,8 @@ Request:
202
235
  "name": "my-webserver",
203
236
  "cpu_cores": 2,
204
237
  "memory_gb": 4,
205
- "storage_gb": 20
238
+ "storage_gb": 20,
239
+ "stream_id": 123 // required when STREAM_PAYMENT_ADDRESS is set
206
240
  }
207
241
  ```
208
242
 
@@ -230,6 +264,25 @@ Response:
230
264
  - Delete VM: `DELETE /api/v1/vms/{vm_id}`
231
265
  - Stop VM: `POST /api/v1/vms/{vm_id}/stop`
232
266
  - Get Access Info: `GET /api/v1/vms/{vm_id}/access`
267
+
268
+ ### Provider Info
269
+
270
+ ```bash
271
+ GET /api/v1/provider/info
272
+ ```
273
+
274
+ Response:
275
+
276
+ ```json
277
+ {
278
+ "provider_id": "0xProviderEthereumAddress",
279
+ "stream_payment_address": "0xStreamPayment",
280
+ "glm_token_address": "0x0000000000000000000000000000000000000000"
281
+
282
+ }
283
+ ```
284
+
285
+ Use this endpoint to discover the correct recipient for creating a GLM stream.
233
286
 
234
287
  ## Operations
235
288
 
@@ -239,10 +292,26 @@ Response:
239
292
  # To run in production mode
240
293
  poetry run golem-provider start
241
294
 
242
- # To run in development mode
243
- poetry run golem-provider dev
295
+ # To run in development mode, set the environment and optionally network
296
+ GOLEM_PROVIDER_ENVIRONMENT=development poetry run golem-provider start --network testnet
244
297
  ```
245
298
 
299
+ ### Mode vs. Network
300
+
301
+ - Development Mode (`GOLEM_PROVIDER_ENVIRONMENT=development`)
302
+ - Optimizes for local iteration: enables reload + debug logging and uses local defaults (e.g., local port check servers). May derive a local/LAN IP automatically and prefix the provider name with `DEVMODE-`.
303
+ - Does not decide which chain you target.
304
+
305
+ - Network Selection (`--network` or `GOLEM_PROVIDER_NETWORK`)
306
+ - Chooses the discovery/advertisement scope: providers advertise `golem_network=testnet|mainnet` and requestors filter accordingly.
307
+ - Pair with appropriate RPC envs (`GOLEM_PROVIDER_GOLEM_BASE_RPC_URL`, `GOLEM_PROVIDER_GOLEM_BASE_WS_URL`).
308
+ - Does not change dev ergonomics (logging, reload, or port verification behavior).
309
+
310
+ Common setups:
311
+ - Local dev on testnet: `GOLEM_PROVIDER_ENVIRONMENT=development` plus `--network testnet`.
312
+ - Staging on testnet: keep `ENVIRONMENT=production`, set `--network testnet` and testnet RPCs.
313
+ - Production on mainnet: `ENVIRONMENT=production` with `--network mainnet` and mainnet RPCs.
314
+
246
315
  The provider will:
247
316
 
248
317
  1. Verify port accessibility
@@ -254,6 +323,20 @@ The provider will:
254
323
  4. Begin resource advertisement
255
324
  5. Listen for VM requests
256
325
 
326
+ ### Faucet
327
+
328
+ - L3 (Golem Base adverts): provider auto-requests funds on startup from `FAUCET_URL` (defaults to EthWarsaw Holesky) protected by CAPTCHA at `CAPTCHA_URL/05381a2cef5e`.
329
+ - L2 (payments): Use the CLI to request native ETH:
330
+
331
+ ```bash
332
+ poetry run golem-provider wallet faucet-l2
333
+ ```
334
+
335
+ Defaults:
336
+ - L2 faucet: `https://l2.holesky.golemdb.io/faucet`
337
+ - CAPTCHA: `https://cap.gobas.me/05381a2cef5e`
338
+ - Override with env: `GOLEM_PROVIDER_L2_FAUCET_URL`, `GOLEM_PROVIDER_L2_CAPTCHA_URL`, `GOLEM_PROVIDER_L2_CAPTCHA_API_KEY`.
339
+
257
340
  ### Resource Advertisement Flow
258
341
 
259
342
  ```mermaid
@@ -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
@@ -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
 
@@ -25,12 +26,22 @@ async def create_vm(
25
26
  request: CreateVMRequest,
26
27
  vm_service: VMService = Depends(Provide[Container.vm_service]),
27
28
  settings: Settings = Depends(Provide[Container.config]),
29
+ stream_map = Depends(Provide[Container.stream_map]),
28
30
  ) -> VMInfo:
29
31
  """Create a new VM."""
30
32
  try:
31
33
  logger.info(f"📥 Received VM creation request for '{request.name}'")
32
34
 
33
35
  resources = request.resources or VMResources()
36
+
37
+ # If payments are enabled, require a valid stream before starting
38
+ if settings["STREAM_PAYMENT_ADDRESS"] and settings["STREAM_PAYMENT_ADDRESS"] != "0x0000000000000000000000000000000000000000":
39
+ if request.stream_id is None:
40
+ raise HTTPException(status_code=400, detail="stream_id required when payments are enabled")
41
+ reader = StreamPaymentReader(settings["POLYGON_RPC_URL"], settings["STREAM_PAYMENT_ADDRESS"])
42
+ ok, reason = reader.verify_stream(int(request.stream_id), settings["PROVIDER_ID"])
43
+ if not ok:
44
+ raise HTTPException(status_code=400, detail=f"invalid stream: {reason}")
34
45
 
35
46
  # Create VM config
36
47
  config = VMConfig(
@@ -41,11 +52,20 @@ async def create_vm(
41
52
  )
42
53
 
43
54
  vm_info = await vm_service.create_vm(config)
55
+ # Persist VM->stream mapping if provided
56
+ if request.stream_id is not None:
57
+ try:
58
+ await stream_map.set(vm_info.id, int(request.stream_id))
59
+ except Exception as e:
60
+ logger.warning(f"failed to persist stream mapping for {vm_info.id}: {e}")
44
61
  await vm_creation_animation(request.name)
45
62
  return vm_info
46
63
  except MultipassError as e:
47
64
  logger.error(f"Failed to create VM: {e}")
48
65
  raise HTTPException(status_code=500, detail=str(e))
66
+ except HTTPException:
67
+ # Propagate explicit HTTP errors (e.g., payment gating)
68
+ raise
49
69
  except Exception as e:
50
70
  logger.error(f"An unexpected error occurred: {e}")
51
71
  raise HTTPException(status_code=500, detail="An unexpected error occurred")
@@ -151,12 +171,17 @@ async def stop_vm(
151
171
  async def delete_vm(
152
172
  requestor_name: str,
153
173
  vm_service: VMService = Depends(Provide[Container.vm_service]),
174
+ stream_map = Depends(Provide[Container.stream_map]),
154
175
  ) -> None:
155
176
  """Delete a VM."""
156
177
  try:
157
178
  logger.process(f"🗑️ Deleting VM '{requestor_name}'")
158
179
  vm_status_change(requestor_name, "STOPPING", "Cleanup in progress")
159
180
  await vm_service.delete_vm(requestor_name)
181
+ try:
182
+ await stream_map.remove(requestor_name)
183
+ except Exception as e:
184
+ logger.warning(f"failed to remove stream mapping for {requestor_name}: {e}")
160
185
  vm_status_change(requestor_name, "TERMINATED", "Cleanup complete")
161
186
  logger.success(f"✨ Successfully deleted VM '{requestor_name}'")
162
187
  except VMNotFoundError as e:
@@ -168,3 +193,11 @@ async def delete_vm(
168
193
  except Exception as e:
169
194
  logger.error(f"An unexpected error occurred: {e}")
170
195
  raise HTTPException(status_code=500, detail="An unexpected error occurred")
196
+ @router.get("/provider/info", response_model=ProviderInfoResponse)
197
+ @inject
198
+ async def provider_info(settings: Settings = Depends(Provide[Container.config])) -> ProviderInfoResponse:
199
+ return ProviderInfoResponse(
200
+ provider_id=settings["PROVIDER_ID"],
201
+ stream_payment_address=settings["STREAM_PAYMENT_ADDRESS"],
202
+ glm_token_address=settings["GLM_TOKEN_ADDRESS"],
203
+ )
@@ -6,6 +6,7 @@ import socket
6
6
 
7
7
  from pydantic_settings import BaseSettings
8
8
  from pydantic import field_validator, Field
9
+ import os
9
10
  from .utils.logging import setup_logger
10
11
 
11
12
  logger = setup_logger(__name__)
@@ -52,6 +53,8 @@ class Settings(BaseSettings):
52
53
  PORT: int = 7466
53
54
  SKIP_PORT_VERIFICATION: bool = False
54
55
  ENVIRONMENT: str = "production"
56
+ # Logical network selector for annotation and client defaults
57
+ NETWORK: str = "mainnet" # one of: "testnet", "mainnet"
55
58
 
56
59
  @property
57
60
  def DEV_MODE(self) -> bool:
@@ -68,9 +71,6 @@ class Settings(BaseSettings):
68
71
  ETHEREUM_KEY_DIR: str = ""
69
72
  ETHEREUM_PRIVATE_KEY: Optional[str] = None
70
73
  PROVIDER_ID: str = "" # Will be set from Ethereum identity
71
- FAUCET_URL: str = "https://ethwarsaw.holesky.golemdb.io/faucet"
72
- CAPTCHA_URL: str = "https://cap.gobas.me"
73
- CAPTCHA_API_KEY: str = "05381a2cef5e"
74
74
 
75
75
  @field_validator("ETHEREUM_KEY_DIR", mode='before')
76
76
  def resolve_key_dir(cls, v: str) -> str:
@@ -135,6 +135,72 @@ class Settings(BaseSettings):
135
135
  GOLEM_BASE_RPC_URL: str = "https://ethwarsaw.holesky.golemdb.io/rpc"
136
136
  GOLEM_BASE_WS_URL: str = "wss://ethwarsaw.holesky.golemdb.io/rpc/ws"
137
137
 
138
+ # Polygon / Payments
139
+ POLYGON_RPC_URL: str = Field(
140
+ default="https://l2.holesky.golemdb.io/rpc",
141
+ description="EVM RPC URL for streaming payments (L2 by default)"
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="Token address (0x0 means native ETH)"
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
+
176
+ # Faucet settings (L3 for Golem Base adverts)
177
+ FAUCET_URL: str = "https://ethwarsaw.holesky.golemdb.io/faucet"
178
+ CAPTCHA_URL: str = "https://cap.gobas.me"
179
+ CAPTCHA_API_KEY: str = "05381a2cef5e"
180
+
181
+ # L2 payments faucet (native ETH)
182
+ L2_FAUCET_URL: str = Field(
183
+ default="https://l2.holesky.golemdb.io/faucet",
184
+ description="L2 faucet base URL (no trailing /api)"
185
+ )
186
+ L2_CAPTCHA_URL: str = Field(
187
+ default="https://cap.gobas.me",
188
+ description="CAPTCHA base URL"
189
+ )
190
+ L2_CAPTCHA_API_KEY: str = Field(
191
+ default="05381a2cef5e",
192
+ description="CAPTCHA API key path segment"
193
+ )
194
+
195
+ @field_validator("POLYGON_RPC_URL", mode='before')
196
+ @classmethod
197
+ def prefer_custom_env(cls, v: str) -> str:
198
+ # Accept alternative aliases for payments RPC
199
+ for key in ("GOLEM_PROVIDER_L2_RPC_URL", "GOLEM_PROVIDER_KAOLIN_RPC_URL"):
200
+ if os.environ.get(key):
201
+ return os.environ[key]
202
+ return v
203
+
138
204
  # VM Settings
139
205
  MAX_VMS: int = 10
140
206
  DEFAULT_VM_IMAGE: str = "ubuntu:24.04"
@@ -14,6 +14,9 @@ from .vm.service import VMService
14
14
  from .vm.name_mapper import VMNameMapper
15
15
  from .vm.port_manager import PortManager
16
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
17
20
 
18
21
 
19
22
  class Container(containers.DeclarativeContainer):
@@ -49,6 +52,11 @@ class Container(containers.DeclarativeContainer):
49
52
  db_path=Path(settings.VM_DATA_DIR) / "vm_names.json",
50
53
  )
51
54
 
55
+ stream_map = providers.Singleton(
56
+ StreamMap,
57
+ storage_path=Path(settings.VM_DATA_DIR) / "streams.json",
58
+ )
59
+
52
60
  port_manager = providers.Singleton(
53
61
  PortManager,
54
62
  start_port=config.PORT_RANGE_START,
@@ -81,6 +89,31 @@ class Container(containers.DeclarativeContainer):
81
89
  name_mapper=vm_name_mapper,
82
90
  )
83
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
+
84
117
  provider_service = providers.Singleton(
85
118
  ProviderService,
86
119
  vm_service=vm_service,
@@ -66,6 +66,7 @@ class GolemBaseAdvertiser(Advertiser):
66
66
 
67
67
  string_annotations = [
68
68
  Annotation(key="golem_type", value="provider"),
69
+ Annotation(key="golem_network", value=settings.NETWORK),
69
70
  Annotation(key="golem_provider_id", value=settings.PROVIDER_ID),
70
71
  Annotation(key="golem_ip_address", value=ip_address),
71
72
  Annotation(key="golem_country", value=settings.PROVIDER_COUNTRY),
@@ -122,7 +122,9 @@ except ImportError:
122
122
 
123
123
  cli = typer.Typer()
124
124
  pricing_app = typer.Typer(help="Configure USD pricing; auto-converts to GLM.")
125
+ wallet_app = typer.Typer(help="Wallet utilities (funding, balance)")
125
126
  cli.add_typer(pricing_app, name="pricing")
127
+ cli.add_typer(wallet_app, name="wallet")
126
128
 
127
129
  def print_version(ctx: typer.Context, value: bool):
128
130
  if not value:
@@ -141,15 +143,57 @@ def main(
141
143
  ensure_config()
142
144
  pass
143
145
 
146
+
147
+ @wallet_app.command("faucet-l2")
148
+ def wallet_faucet_l2():
149
+ """Request L2 faucet funds for the provider's payment address (native ETH)."""
150
+ from .config import settings
151
+ from golem_faucet import PowFaucetClient
152
+ from web3 import Web3
153
+ try:
154
+ addr = settings.PROVIDER_ID
155
+ faucet = PowFaucetClient(settings.L2_FAUCET_URL, settings.L2_CAPTCHA_URL, settings.L2_CAPTCHA_API_KEY)
156
+ # Check current L2 balance
157
+ w3 = Web3(Web3.HTTPProvider(settings.POLYGON_RPC_URL))
158
+ bal = 0.0
159
+ try:
160
+ bal = float(w3.from_wei(w3.eth.get_balance(Web3.to_checksum_address(addr)), 'ether'))
161
+ except Exception:
162
+ pass
163
+ if bal > 0.01:
164
+ print(f"Sufficient L2 funds ({bal} ETH); skipping faucet.")
165
+ return
166
+ async def _run():
167
+ chall = await faucet.get_challenge()
168
+ if not chall:
169
+ print("Failed to get challenge")
170
+ raise typer.Exit(code=1)
171
+ sols = []
172
+ for salt, target in chall.get('challenge') or []:
173
+ sols.append((salt, target, PowFaucetClient.solve_challenge(salt, target)))
174
+ redeemed = await faucet.redeem(chall.get('token'), sols)
175
+ if not redeemed:
176
+ print("Failed to redeem solutions")
177
+ raise typer.Exit(code=1)
178
+ tx = await faucet.request_funds(addr, redeemed)
179
+ if tx:
180
+ print(f"Faucet tx: {tx}")
181
+ else:
182
+ print("Faucet request failed")
183
+ asyncio.run(_run())
184
+ except Exception as e:
185
+ print(f"Error: {e}")
186
+ raise typer.Exit(code=1)
187
+
144
188
  @cli.command()
145
- def start(no_verify_port: bool = typer.Option(False, "--no-verify-port", help="Skip provider port verification.")):
189
+ def start(
190
+ no_verify_port: bool = typer.Option(False, "--no-verify-port", help="Skip provider port verification."),
191
+ network: str = typer.Option(None, "--network", help="Target network: 'testnet' or 'mainnet' (overrides env)")
192
+ ):
146
193
  """Start the provider server."""
147
- run_server(dev_mode=False, no_verify_port=no_verify_port)
194
+ run_server(dev_mode=False, no_verify_port=no_verify_port, network=network)
148
195
 
149
- @cli.command()
150
- def dev(no_verify_port: bool = typer.Option(True, "--no-verify-port", help="Skip provider port verification.")):
151
- """Start the provider server in development mode."""
152
- run_server(dev_mode=True, no_verify_port=no_verify_port)
196
+ # Removed separate 'dev' command; use environment GOLEM_PROVIDER_ENVIRONMENT=development instead.
153
197
 
154
198
  def _env_path_for(dev_mode: Optional[bool]) -> str:
155
199
  from pathlib import Path
@@ -209,22 +253,35 @@ def _print_pricing_examples(glm_usd):
209
253
  f"- {name} ({res.cpu}C, {res.memory}GB RAM, {res.storage}GB Disk): ~{usd_str} per month (~{glm_str})"
210
254
  )
211
255
 
212
- def run_server(dev_mode: bool, no_verify_port: bool):
256
+ def run_server(dev_mode: bool | None = None, no_verify_port: bool = False, network: str | None = None):
213
257
  """Helper to run the uvicorn server."""
214
258
  import sys
215
259
  from pathlib import Path
216
260
  from dotenv import load_dotenv
217
261
  import uvicorn
218
- # Load appropriate .env file
262
+ # Decide dev mode from explicit arg or environment
263
+ if dev_mode is None:
264
+ dev_mode = os.environ.get("GOLEM_PROVIDER_ENVIRONMENT", "").lower() == "development"
265
+
266
+ # Load appropriate .env file based on mode
219
267
  env_file = ".env.dev" if dev_mode else ".env"
220
268
  env_path = Path(__file__).parent.parent / env_file
221
269
  load_dotenv(dotenv_path=env_path)
270
+
271
+ # Apply network override early (affects settings and annotations)
272
+ if network:
273
+ os.environ["GOLEM_PROVIDER_NETWORK"] = network
222
274
 
223
275
  # The logic for setting the public IP in dev mode is now handled in config.py
224
276
  # The following lines are no longer needed and have been removed.
225
277
 
226
278
  # Import settings after loading env
227
279
  from .config import settings
280
+ if network:
281
+ try:
282
+ settings.NETWORK = network
283
+ except Exception:
284
+ pass
228
285
 
229
286
  # Configure logging with debug mode
230
287
  logger = setup_logger(__name__, debug=dev_mode)
@@ -235,6 +292,8 @@ def run_server(dev_mode: bool, no_verify_port: bool):
235
292
  for key, value in os.environ.items():
236
293
  if key.startswith('GOLEM_PROVIDER_'):
237
294
  logger.info(f"{key}={value}")
295
+ if network:
296
+ logger.info(f"Overridden network: {network}")
238
297
 
239
298
  # Check requirements
240
299
  if not check_requirements():
@@ -257,7 +316,7 @@ def run_server(dev_mode: bool, no_verify_port: bool):
257
316
  "provider:app",
258
317
  host=settings.HOST,
259
318
  port=settings.PORT,
260
- reload=settings.DEBUG,
319
+ reload=dev_mode,
261
320
  log_level="debug" if dev_mode else "info",
262
321
  log_config=log_config,
263
322
  timeout_keep_alive=60, # Increase keep-alive timeout
@@ -0,0 +1,93 @@
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
+ from golem_streaming_abi import STREAM_PAYMENT_ABI
9
+
10
+
11
+ # ABI imported from shared package
12
+
13
+
14
+ @dataclass
15
+ class StreamPaymentConfig:
16
+ rpc_url: str
17
+ contract_address: str
18
+ private_key: str
19
+
20
+
21
+ class StreamPaymentClient:
22
+ def __init__(self, cfg: StreamPaymentConfig):
23
+ self.web3 = Web3(Web3.HTTPProvider(cfg.rpc_url))
24
+ self.account = Account.from_key(cfg.private_key)
25
+ self.web3.eth.default_account = self.account.address
26
+ self.contract = self.web3.eth.contract(
27
+ address=Web3.to_checksum_address(cfg.contract_address), abi=STREAM_PAYMENT_ABI
28
+ )
29
+
30
+ def _send(self, fn) -> Dict[str, Any]:
31
+ tx = fn.build_transaction(
32
+ {
33
+ "from": self.account.address,
34
+ "nonce": self.web3.eth.get_transaction_count(self.account.address),
35
+ }
36
+ )
37
+ if hasattr(self.account, "sign_transaction"):
38
+ signed = self.account.sign_transaction(tx)
39
+ tx_hash = self.web3.eth.send_raw_transaction(signed.rawTransaction)
40
+ else:
41
+ tx_hash = self.web3.eth.send_transaction(tx)
42
+ receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash)
43
+ return {"transactionHash": tx_hash.hex(), "status": receipt.status}
44
+
45
+ def withdraw(self, stream_id: int) -> str:
46
+ fn = self.contract.functions.withdraw(int(stream_id))
47
+ receipt = self._send(fn)
48
+ return receipt["transactionHash"]
49
+ def terminate(self, stream_id: int) -> str:
50
+ fn = self.contract.functions.terminate(int(stream_id))
51
+ receipt = self._send(fn)
52
+ return receipt["transactionHash"]
53
+
54
+ class StreamPaymentReader:
55
+ def __init__(self, rpc_url: str, contract_address: str):
56
+ self.web3 = Web3(Web3.HTTPProvider(rpc_url))
57
+ self.contract = self.web3.eth.contract(
58
+ address=Web3.to_checksum_address(contract_address), abi=STREAM_PAYMENT_ABI
59
+ )
60
+
61
+ def get_stream(self, stream_id: int) -> dict:
62
+ token, sender, recipient, startTime, stopTime, ratePerSecond, deposit, withdrawn, halted = (
63
+ self.contract.functions.streams(int(stream_id)).call()
64
+ )
65
+ return {
66
+ "token": token,
67
+ "sender": sender,
68
+ "recipient": recipient,
69
+ "startTime": int(startTime),
70
+ "stopTime": int(stopTime),
71
+ "ratePerSecond": int(ratePerSecond),
72
+ "deposit": int(deposit),
73
+ "withdrawn": int(withdrawn),
74
+ "halted": bool(halted),
75
+ }
76
+
77
+ def verify_stream(self, stream_id: int, expected_recipient: str) -> tuple[bool, str]:
78
+ try:
79
+ s = self.get_stream(stream_id)
80
+ except Exception as e:
81
+ return False, f"stream lookup failed: {e}"
82
+ if s["recipient"].lower() != expected_recipient.lower():
83
+ return False, "recipient mismatch"
84
+ if s["deposit"] <= 0:
85
+ return False, "no deposit"
86
+ now = int(self.web3.eth.get_block("latest")["timestamp"])
87
+ if s["startTime"] > now:
88
+ return False, "stream not started"
89
+ if s["halted"]:
90
+ return False, "stream halted"
91
+ return True, "ok"
92
+
93
+ # Reader should remain read-only; no terminate here
@@ -0,0 +1,67 @@
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
+ # Enforce a minimum interval between withdrawals
56
+ if withdrawable >= self.settings.STREAM_MIN_WITHDRAW_WEI and (
57
+ now - last_withdraw >= self.settings.STREAM_WITHDRAW_INTERVAL_SECONDS
58
+ ):
59
+ try:
60
+ self.client.withdraw(stream_id)
61
+ last_withdraw = now
62
+ except Exception as e:
63
+ logger.warning(f"withdraw failed for {stream_id}: {e}")
64
+ except asyncio.CancelledError:
65
+ break
66
+ except Exception as e:
67
+ logger.error(f"stream monitor error: {e}")
@@ -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
+
@@ -1,10 +1,9 @@
1
1
  import asyncio
2
- import hashlib
3
- import httpx
4
- from typing import Optional
2
+ from typing import Optional, List, Tuple
5
3
 
6
4
  from golem_base_sdk import GolemBaseClient
7
5
  from provider.utils.logging import setup_logger
6
+ from golem_faucet import PowFaucetClient
8
7
 
9
8
  logger = setup_logger(__name__)
10
9
 
@@ -13,11 +12,12 @@ class FaucetClient:
13
12
  """A client for interacting with a Proof of Work-protected faucet."""
14
13
 
15
14
  def __init__(self, faucet_url: str, captcha_url: str, captcha_api_key: str):
16
- self.faucet_url = faucet_url
17
- self.captcha_url = captcha_url
15
+ self.faucet_url = faucet_url.rstrip("/")
16
+ self.captcha_url = captcha_url.rstrip("/")
18
17
  self.captcha_api_key = captcha_api_key
19
- self.api_endpoint = f"{faucet_url}/api"
18
+ self.api_endpoint = f"{self.faucet_url}/api"
20
19
  self.client: Optional[GolemBaseClient] = None
20
+ self._pow = PowFaucetClient(self.faucet_url, self.captcha_url, self.captcha_api_key)
21
21
 
22
22
  async def _ensure_client(self):
23
23
  if not self.client:
@@ -82,51 +82,27 @@ class FaucetClient:
82
82
  async def _get_challenge(self) -> Optional[dict]:
83
83
  """Get a PoW challenge from the faucet."""
84
84
  try:
85
- async with httpx.AsyncClient(timeout=60.0) as client:
86
- url = f"{self.captcha_url}/{self.captcha_api_key}/api/challenge"
87
- response = await client.post(url)
88
- response.raise_for_status()
89
- return response.json()
90
- except httpx.HTTPStatusError as e:
91
- logger.error(f"Failed to get PoW challenge: {e.response.text}")
85
+ return await self._pow.get_challenge()
86
+ except Exception as e:
87
+ logger.error(f"Failed to get PoW challenge: {e}")
92
88
  return None
93
89
 
94
90
  def _solve_challenge(self, salt: str, target: str) -> int:
95
91
  """Solve the PoW challenge."""
96
- target_hash = bytes.fromhex(target)
97
- nonce = 0
98
- while True:
99
- hasher = hashlib.sha256()
100
- hasher.update(f"{salt}{nonce}".encode())
101
- if hasher.digest().startswith(target_hash):
102
- return nonce
103
- nonce += 1
92
+ return PowFaucetClient.solve_challenge(salt, target)
104
93
 
105
94
  async def _redeem_solution(self, token: str, solutions: list) -> Optional[str]:
106
95
  """Redeem the PoW solution to get a CAPTCHA token."""
107
96
  try:
108
- async with httpx.AsyncClient(timeout=60.0) as client:
109
- url = f"{self.captcha_url}/{self.captcha_api_key}/api/redeem"
110
- response = await client.post(
111
- url,
112
- json={"token": token, "solutions": solutions}
113
- )
114
- response.raise_for_status()
115
- return response.json().get("token")
116
- except httpx.HTTPStatusError as e:
117
- logger.error(f"Failed to redeem PoW solution: {e.response.text}")
97
+ return await self._pow.redeem(token, solutions)
98
+ except Exception as e:
99
+ logger.error(f"Failed to redeem PoW solution: {e}")
118
100
  return None
119
101
 
120
102
  async def _request_faucet(self, address: str, token: str) -> Optional[str]:
121
103
  """Request funds from the faucet with the CAPTCHA token."""
122
104
  try:
123
- async with httpx.AsyncClient(timeout=60.0) as client:
124
- response = await client.post(
125
- f"{self.api_endpoint}/faucet",
126
- json={"address": address, "captchaToken": token}
127
- )
128
- response.raise_for_status()
129
- return response.json().get("txHash")
130
- except httpx.HTTPStatusError as e:
131
- logger.error(f"Faucet request failed: {e.response.text}")
132
- return None
105
+ return await self._pow.request_funds(address, token)
106
+ except Exception as e:
107
+ logger.error(f"Faucet request failed: {e}")
108
+ return None
@@ -17,6 +17,7 @@ class ProviderService:
17
17
  self.advertisement_service = advertisement_service
18
18
  self.port_manager = port_manager
19
19
  self._pricing_updater: PricingAutoUpdater | None = None
20
+ self._stream_monitor = None
20
21
 
21
22
  async def setup(self, app: FastAPI):
22
23
  """Setup and initialize the provider components."""
@@ -43,6 +44,13 @@ class ProviderService:
43
44
  self._pricing_updater = PricingAutoUpdater(on_updated_callback=_on_price_updated)
44
45
  asyncio.create_task(self._pricing_updater.start())
45
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()
53
+
46
54
  # Check wallet balance and request funds if needed
47
55
  faucet_client = FaucetClient(
48
56
  faucet_url=settings.FAUCET_URL,
@@ -64,6 +72,8 @@ class ProviderService:
64
72
  await self.vm_service.provider.cleanup()
65
73
  if self._pricing_updater:
66
74
  self._pricing_updater.stop()
75
+ if self._stream_monitor:
76
+ await self._stream_monitor.stop()
67
77
  logger.success("✨ Provider cleanup complete")
68
78
 
69
79
  def _setup_directories(self):
@@ -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()
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "golem-vm-provider"
3
- version = "0.1.38"
3
+ version = "0.1.42"
4
4
  description = "VM on Golem Provider Node - Run your own provider node to offer VMs on the Golem Network"
5
5
  authors = ["Phillip Jensen <phillip+vm-on-golem@golemgrid.com>"]
6
6
  readme = "README.md"
@@ -21,7 +21,6 @@ packages = [
21
21
 
22
22
  [tool.poetry.scripts]
23
23
  golem-provider = "provider.main:cli"
24
- dev = "provider.main:dev"
25
24
 
26
25
  [tool.poetry.dependencies]
27
26
  python = "^3.11"
@@ -46,10 +45,12 @@ typer = "^0.4.0"
46
45
  web3 = "==7.13.0"
47
46
  golem-base-sdk = "==0.1.0"
48
47
  dependency-injector = "^4.41.0"
48
+ golem-streaming-abi = ">=0.1.0,<0.2.0"
49
+ golem-faucet = ">=0.1.0,<0.2.0"
49
50
 
50
51
  [tool.poetry.group.dev.dependencies]
51
- pytest = "^7.0.0"
52
- pytest-asyncio = "^0.18.0"
52
+ pytest = "^8.2.0"
53
+ pytest-asyncio = "^1.1.0"
53
54
  pytest-mock = "^3.8.2"
54
55
  pytest-cov = "^3.0.0"
55
56
  black = "^22.3.0"