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.
- {golem_vm_provider-0.1.38.dist-info → golem_vm_provider-0.1.39.dist-info}/METADATA +44 -2
- {golem_vm_provider-0.1.38.dist-info → golem_vm_provider-0.1.39.dist-info}/RECORD +13 -10
- provider/api/models.py +10 -0
- provider/api/routes.py +22 -1
- provider/config.py +38 -0
- provider/container.py +33 -0
- provider/payments/blockchain_service.py +135 -0
- provider/payments/monitor.py +64 -0
- provider/payments/stream_map.py +40 -0
- provider/service.py +10 -0
- provider/vm/service.py +19 -2
- {golem_vm_provider-0.1.38.dist-info → golem_vm_provider-0.1.39.dist-info}/WHEEL +0 -0
- {golem_vm_provider-0.1.38.dist-info → golem_vm_provider-0.1.39.dist-info}/entry_points.txt +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.3
|
2
2
|
Name: golem-vm-provider
|
3
|
-
Version: 0.1.
|
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=
|
4
|
-
provider/api/routes.py,sha256=
|
5
|
-
provider/config.py,sha256=
|
6
|
-
provider/container.py,sha256=
|
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=
|
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=
|
37
|
-
golem_vm_provider-0.1.
|
38
|
-
golem_vm_provider-0.1.
|
39
|
-
golem_vm_provider-0.1.
|
40
|
-
golem_vm_provider-0.1.
|
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
|
-
|
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()
|
File without changes
|
File without changes
|