golem-vm-provider 0.1.38__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.38
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,9 +1,9 @@
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=gURUkVFATFiwZ3vboO0jPYUF7PqKDybLBm4Zr4OF14c,20294
6
- provider/container.py,sha256=0FYoZf8GVArJ7MbGXHZmnMucKAmVhF7ZcCt5_5qGiYE,2619
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
8
  provider/discovery/advertiser.py,sha256=o-LiDl1j0lXMUU0-zPe3qerjpoD2360EA60Y_V_VeBc,6571
9
9
  provider/discovery/golem_base_advertiser.py,sha256=_UgxsedmBvSRX919wCp4Wo-pZV5fncesXj1k2h3jkXY,6869
@@ -14,9 +14,12 @@ provider/discovery/resource_tracker.py,sha256=MP7IXd3aIMsjB4xz5Oj9zFDTEnvrnw-Cyx
14
14
  provider/discovery/service.py,sha256=vX_mVSxvn3arnb2cKDM_SeJp1ZgPdImP2aUubeXgdRg,915
15
15
  provider/main.py,sha256=V1n7IFhV0Y2WMjD1_IomEYptdRu1G-K6U0JL8ip8Th8,11962
16
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
17
20
  provider/security/ethereum.py,sha256=EwPZj4JR8OEpto6LhKjuuT3Z9pBX6P7-UQaqJtqFkYQ,1242
18
21
  provider/security/faucet.py,sha256=O2DgP3bIrRUm9tdLCdgnda9em0rPyeW42sWhO1EQJaA,5363
19
- provider/service.py,sha256=6Yv46SZu-25vN_SbzlymijZ-v37yY5t1wTqh6QlXhRM,2939
22
+ provider/service.py,sha256=IIjeSM9T4r616nBRnxCUum_sgbyRusMMcja3yQd8zQI,3383
20
23
  provider/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
24
  provider/utils/ascii_art.py,sha256=ykBFsztk57GIiz1NJ-EII5UvN74iECqQL4h9VmiW6Z8,3161
22
25
  provider/utils/logging.py,sha256=VV3oTYSRT8hUejtXLuua1M6kCHmIJgPspIkzsUVhYW0,1920
@@ -33,8 +36,8 @@ provider/vm/name_mapper.py,sha256=14nKfCjJ1WkXfC4vnCYIxNGQUwcl2vcxrJYUAz4fL40,40
33
36
  provider/vm/port_manager.py,sha256=iYSwjTjD_ziOhG8aI7juKHw1OwwRUTJQyQoRUNQvz9w,12514
34
37
  provider/vm/provider.py,sha256=A7QN89EJjcSS40_SmKeinG1Jp_NGffJaLse-XdKciAs,1164
35
38
  provider/vm/proxy_manager.py,sha256=n4NTsyz2rtrvjtf_ceKBk-g2q_mzqPwruB1q7UlQVBc,14928
36
- provider/vm/service.py,sha256=7MjJM4l3wylcBlOuADEVab6R5HfT5HInOFZuEGcoV3I,3982
37
- golem_vm_provider-0.1.38.dist-info/METADATA,sha256=1ritYsJarEhy6ghti1N4Sz7Q1mV61YxftPv7b3PFP8A,10943
38
- golem_vm_provider-0.1.38.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
39
- golem_vm_provider-0.1.38.dist-info/entry_points.txt,sha256=iFUajRxfViYkFcPMPv2EIjYm-BLOp86LJj_IjCwwZUw,74
40
- golem_vm_provider-0.1.38.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
@@ -135,6 +135,44 @@ 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://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
+
138
176
  # VM Settings
139
177
  MAX_VMS: int = 10
140
178
  DEFAULT_VM_IMAGE: str = "ubuntu:24.04"
provider/container.py CHANGED
@@ -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,
@@ -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
@@ -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):
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()