request-vm-on-golem 0.1.40__tar.gz → 0.1.44__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 (25) hide show
  1. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/PKG-INFO +98 -1
  2. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/README.md +95 -0
  3. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/pyproject.toml +5 -3
  4. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/cli/commands.py +133 -5
  5. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/config.py +51 -0
  6. request_vm_on_golem-0.1.44/requestor/payments/blockchain_service.py +161 -0
  7. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/provider/client.py +21 -10
  8. request_vm_on_golem-0.1.44/requestor/security/faucet.py +54 -0
  9. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/services/provider_service.py +3 -0
  10. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/services/vm_service.py +29 -4
  11. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/__init__.py +0 -0
  12. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/api/main.py +0 -0
  13. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/cli/__init__.py +0 -0
  14. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/db/__init__.py +0 -0
  15. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/db/sqlite.py +0 -0
  16. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/errors.py +0 -0
  17. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/provider/__init__.py +0 -0
  18. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/run.py +0 -0
  19. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/services/__init__.py +0 -0
  20. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/services/database_service.py +0 -0
  21. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/services/ssh_service.py +0 -0
  22. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/ssh/__init__.py +0 -0
  23. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/ssh/manager.py +0 -0
  24. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/utils/logging.py +0 -0
  25. {request_vm_on_golem-0.1.40 → request_vm_on_golem-0.1.44}/requestor/utils/spinner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: request-vm-on-golem
3
- Version: 0.1.40
3
+ Version: 0.1.44
4
4
  Summary: VM on Golem Requestor CLI - Create and manage virtual machines on the Golem Network
5
5
  Keywords: golem,vm,cloud,decentralized,cli
6
6
  Author: Phillip Jensen
@@ -25,6 +25,8 @@ Requires-Dist: colorlog (>=6.8.0,<7.0.0)
25
25
  Requires-Dist: cryptography (>=3.4.7,<4.0.0)
26
26
  Requires-Dist: fastapi (>=0.103.0,<0.104.0)
27
27
  Requires-Dist: golem-base-sdk (==0.1.0)
28
+ Requires-Dist: golem-faucet (>=0.1.0,<0.2.0)
29
+ Requires-Dist: golem-streaming-abi (>=0.1.0,<0.2.0)
28
30
  Requires-Dist: httptools (>=0.6.0,<0.7.0)
29
31
  Requires-Dist: pydantic (>=2.4.0,<3.0.0)
30
32
  Requires-Dist: pydantic-settings (>=2.1.0,<3.0.0)
@@ -119,6 +121,79 @@ The SSH connection process:
119
121
  2. The provider's proxy system forwards your SSH connection to the VM
120
122
  3. All traffic is securely routed through the allocated port
121
123
 
124
+ ## Streaming Payments (Native ETH on L2)
125
+
126
+ This requestor integrates with an on‑chain StreamPayment contract to enable “pay‑as‑you‑go” rentals using native ETH (no ERC20 approvals when the token address is zero).
127
+
128
+ Flow:
129
+
130
+ 1. Fetch provider info (preferred addresses):
131
+ - `GET http://{provider}:7466/api/v1/provider/info` → `provider_id`, `stream_payment_address`, `glm_token_address` (zero address means native ETH).
132
+ 2. Compute `ratePerSecond` from provider pricing and requested VM resources.
133
+ 3. Ensure `deposit >= ratePerSecond * 3600` (≥ 1 hour runway recommended/minimum).
134
+ 4. Create a stream (`createStream(0x000...0, provider_id, deposit, ratePerSecond)` plus `value=deposit`), capture `stream_id`. For ERC20 mode use a token address and approve first.
135
+ 5. Create VM: `POST /api/v1/vms` with `stream_id` included.
136
+ 6. Top‑up over time with `topUp(stream_id, amount)` to extend stopTime and keep the VM running indefinitely.
137
+ 7. On stop/destroy: the requestor will best‑effort `withdraw` / `terminate` to settle.
138
+
139
+ CLI helpers
140
+
141
+ - Open a stream for a planned VM (computes rate from provider pricing):
142
+
143
+ ```bash
144
+ poetry run golem vm stream open \
145
+ --provider-id 0xProvider \
146
+ --cpu 2 --memory 4 --storage 20 \
147
+ --hours 1
148
+ # prints { stream_id, rate_per_second_wei, deposit_wei }
149
+ ```
150
+
151
+ - Top up an existing stream:
152
+
153
+ ```bash
154
+ # Add 3 hours at prior rate
155
+ poetry run golem vm stream topup --stream-id 123 --hours 3
156
+
157
+ # Or specify exact GLM amount
158
+ poetry run golem vm stream topup --stream-id 123 --glm 25.0
159
+ ```
160
+
161
+ - Create a VM and attach an existing stream (no auto-streams are created by the requestor):
162
+
163
+ ```bash
164
+ poetry run golem vm create my-vm \
165
+ --provider-id 0xProvider \
166
+ --cpu 2 --memory 4 --storage 20 \
167
+ --stream-id 123
168
+ ```
169
+
170
+ Environment (env prefix `GOLEM_REQUESTOR_`):
171
+
172
+ - `polygon_rpc_url` — EVM RPC URL (default L2 RPC)
173
+ - `stream_payment_address` — StreamPayment address (fallback if provider doesn’t advertise)
174
+ - `glm_token_address` — Token address; set to zero address to use native ETH
175
+ - `provider_eth_address` — optional dev helper; in production always use `/provider/info`
176
+ - `network` — Target network for discovery filtering: `testnet` (default) or `mainnet`
177
+
178
+ Efficiency tips:
179
+
180
+ - Batch top‑ups (e.g., add several hours at once) to reduce on‑chain calls.
181
+ - Withdrawals are typically executed by providers; requestors don’t need to withdraw.
182
+ - The CLI `vm stream open` will prefer the provider’s advertised contract/token addresses to prevent mismatches.
183
+
184
+ ## Faucet (L2 ETH)
185
+
186
+ - Request L2 test ETH to cover stream transactions:
187
+
188
+ ```bash
189
+ poetry run golem wallet faucet
190
+ ```
191
+
192
+ - Defaults:
193
+ - Faucet: `https://l2.holesky.golemdb.io/faucet`
194
+ - CAPTCHA: `https://cap.gobas.me/05381a2cef5e`
195
+ - Override with env: `GOLEM_REQUESTOR_l2_faucet_url`, `GOLEM_REQUESTOR_captcha_url`, `GOLEM_REQUESTOR_captcha_api_key`.
196
+
122
197
  ## Installation
123
198
 
124
199
  ```bash
@@ -159,6 +234,27 @@ Alternatively, you can prepend the environment variables directly to the command
159
234
  GOLEM_REQUESTOR_ENVIRONMENT="development" GOLEM_REQUESTOR_FORCE_LOCALHOST="true" poetry run golem vm providers
160
235
  ```
161
236
 
237
+ ### Mode vs. Network
238
+
239
+ - Development Mode (`GOLEM_REQUESTOR_ENVIRONMENT=development`)
240
+ - Improves local workflows: prefixes central discovery URL with `DEVMODE-` and, when using the central driver, maps provider IPs to `localhost` for easier testing.
241
+ - Does not determine chain selection.
242
+
243
+ - Network Selection (`--network` or `GOLEM_REQUESTOR_NETWORK`)
244
+ - Filters Golem Base discovery results by `golem_network=testnet|mainnet`.
245
+ - Combine with the appropriate RPC envs (`GOLEM_REQUESTOR_GOLEM_BASE_RPC_URL`, `GOLEM_REQUESTOR_GOLEM_BASE_WS_URL`) and any contract addresses.
246
+ - Independent from dev ergonomics.
247
+
248
+ Examples:
249
+ - List providers on mainnet without changing env:
250
+ ```bash
251
+ poetry run golem vm providers --network mainnet
252
+ ```
253
+ - Create a VM while targeting testnet:
254
+ ```bash
255
+ poetry run golem vm create my-vm --provider-id 0xProvider --cpu 2 --memory 4 --storage 20 --network testnet
256
+ ```
257
+
162
258
  ## Usage
163
259
 
164
260
  ### Provider Discovery
@@ -276,6 +372,7 @@ export GOLEM_REQUESTOR_DB_PATH="/path/to/database.db"
276
372
  # Environment Mode (defaults to "production")
277
373
  export GOLEM_REQUESTOR_ENVIRONMENT="development" # Optional: Switch to development mode
278
374
  export GOLEM_REQUESTOR_FORCE_LOCALHOST="true" # Optional: Force localhost in development mode
375
+ export GOLEM_REQUESTOR_NETWORK="testnet" # Or "mainnet"; filters Golem Base results by annotation
279
376
  ```
280
377
 
281
378
  2. Directory Structure:
@@ -80,6 +80,79 @@ The SSH connection process:
80
80
  2. The provider's proxy system forwards your SSH connection to the VM
81
81
  3. All traffic is securely routed through the allocated port
82
82
 
83
+ ## Streaming Payments (Native ETH on L2)
84
+
85
+ This requestor integrates with an on‑chain StreamPayment contract to enable “pay‑as‑you‑go” rentals using native ETH (no ERC20 approvals when the token address is zero).
86
+
87
+ Flow:
88
+
89
+ 1. Fetch provider info (preferred addresses):
90
+ - `GET http://{provider}:7466/api/v1/provider/info` → `provider_id`, `stream_payment_address`, `glm_token_address` (zero address means native ETH).
91
+ 2. Compute `ratePerSecond` from provider pricing and requested VM resources.
92
+ 3. Ensure `deposit >= ratePerSecond * 3600` (≥ 1 hour runway recommended/minimum).
93
+ 4. Create a stream (`createStream(0x000...0, provider_id, deposit, ratePerSecond)` plus `value=deposit`), capture `stream_id`. For ERC20 mode use a token address and approve first.
94
+ 5. Create VM: `POST /api/v1/vms` with `stream_id` included.
95
+ 6. Top‑up over time with `topUp(stream_id, amount)` to extend stopTime and keep the VM running indefinitely.
96
+ 7. On stop/destroy: the requestor will best‑effort `withdraw` / `terminate` to settle.
97
+
98
+ CLI helpers
99
+
100
+ - Open a stream for a planned VM (computes rate from provider pricing):
101
+
102
+ ```bash
103
+ poetry run golem vm stream open \
104
+ --provider-id 0xProvider \
105
+ --cpu 2 --memory 4 --storage 20 \
106
+ --hours 1
107
+ # prints { stream_id, rate_per_second_wei, deposit_wei }
108
+ ```
109
+
110
+ - Top up an existing stream:
111
+
112
+ ```bash
113
+ # Add 3 hours at prior rate
114
+ poetry run golem vm stream topup --stream-id 123 --hours 3
115
+
116
+ # Or specify exact GLM amount
117
+ poetry run golem vm stream topup --stream-id 123 --glm 25.0
118
+ ```
119
+
120
+ - Create a VM and attach an existing stream (no auto-streams are created by the requestor):
121
+
122
+ ```bash
123
+ poetry run golem vm create my-vm \
124
+ --provider-id 0xProvider \
125
+ --cpu 2 --memory 4 --storage 20 \
126
+ --stream-id 123
127
+ ```
128
+
129
+ Environment (env prefix `GOLEM_REQUESTOR_`):
130
+
131
+ - `polygon_rpc_url` — EVM RPC URL (default L2 RPC)
132
+ - `stream_payment_address` — StreamPayment address (fallback if provider doesn’t advertise)
133
+ - `glm_token_address` — Token address; set to zero address to use native ETH
134
+ - `provider_eth_address` — optional dev helper; in production always use `/provider/info`
135
+ - `network` — Target network for discovery filtering: `testnet` (default) or `mainnet`
136
+
137
+ Efficiency tips:
138
+
139
+ - Batch top‑ups (e.g., add several hours at once) to reduce on‑chain calls.
140
+ - Withdrawals are typically executed by providers; requestors don’t need to withdraw.
141
+ - The CLI `vm stream open` will prefer the provider’s advertised contract/token addresses to prevent mismatches.
142
+
143
+ ## Faucet (L2 ETH)
144
+
145
+ - Request L2 test ETH to cover stream transactions:
146
+
147
+ ```bash
148
+ poetry run golem wallet faucet
149
+ ```
150
+
151
+ - Defaults:
152
+ - Faucet: `https://l2.holesky.golemdb.io/faucet`
153
+ - CAPTCHA: `https://cap.gobas.me/05381a2cef5e`
154
+ - Override with env: `GOLEM_REQUESTOR_l2_faucet_url`, `GOLEM_REQUESTOR_captcha_url`, `GOLEM_REQUESTOR_captcha_api_key`.
155
+
83
156
  ## Installation
84
157
 
85
158
  ```bash
@@ -120,6 +193,27 @@ Alternatively, you can prepend the environment variables directly to the command
120
193
  GOLEM_REQUESTOR_ENVIRONMENT="development" GOLEM_REQUESTOR_FORCE_LOCALHOST="true" poetry run golem vm providers
121
194
  ```
122
195
 
196
+ ### Mode vs. Network
197
+
198
+ - Development Mode (`GOLEM_REQUESTOR_ENVIRONMENT=development`)
199
+ - Improves local workflows: prefixes central discovery URL with `DEVMODE-` and, when using the central driver, maps provider IPs to `localhost` for easier testing.
200
+ - Does not determine chain selection.
201
+
202
+ - Network Selection (`--network` or `GOLEM_REQUESTOR_NETWORK`)
203
+ - Filters Golem Base discovery results by `golem_network=testnet|mainnet`.
204
+ - Combine with the appropriate RPC envs (`GOLEM_REQUESTOR_GOLEM_BASE_RPC_URL`, `GOLEM_REQUESTOR_GOLEM_BASE_WS_URL`) and any contract addresses.
205
+ - Independent from dev ergonomics.
206
+
207
+ Examples:
208
+ - List providers on mainnet without changing env:
209
+ ```bash
210
+ poetry run golem vm providers --network mainnet
211
+ ```
212
+ - Create a VM while targeting testnet:
213
+ ```bash
214
+ poetry run golem vm create my-vm --provider-id 0xProvider --cpu 2 --memory 4 --storage 20 --network testnet
215
+ ```
216
+
123
217
  ## Usage
124
218
 
125
219
  ### Provider Discovery
@@ -237,6 +331,7 @@ export GOLEM_REQUESTOR_DB_PATH="/path/to/database.db"
237
331
  # Environment Mode (defaults to "production")
238
332
  export GOLEM_REQUESTOR_ENVIRONMENT="development" # Optional: Switch to development mode
239
333
  export GOLEM_REQUESTOR_FORCE_LOCALHOST="true" # Optional: Force localhost in development mode
334
+ export GOLEM_REQUESTOR_NETWORK="testnet" # Or "mainnet"; filters Golem Base results by annotation
240
335
  ```
241
336
 
242
337
  2. Directory Structure:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "request-vm-on-golem"
3
- version = "0.1.40"
3
+ version = "0.1.44"
4
4
  description = "VM on Golem Requestor CLI - Create and manage virtual machines on the Golem Network"
5
5
  authors = ["Phillip Jensen <phillip+vm-on-golem@golemgrid.com>"]
6
6
  readme = "README.md"
@@ -39,10 +39,12 @@ golem-base-sdk = "==0.1.0"
39
39
  httptools = "^0.6.0"
40
40
  pydantic-settings = "^2.1.0"
41
41
  web3 = "==7.13.0"
42
+ golem-streaming-abi = ">=0.1.0,<0.2.0"
43
+ golem-faucet = ">=0.1.0,<0.2.0"
42
44
 
43
45
  [tool.poetry.group.dev.dependencies]
44
- pytest = "^7.0.0"
45
- pytest-asyncio = "^0.18.0"
46
+ pytest = "^8.2.0"
47
+ pytest-asyncio = "^1.1.0"
46
48
  pytest-cov = "^3.0.0"
47
49
  httpx = "^0.23.0"
48
50
  black = "^22.3.0"
@@ -75,9 +75,17 @@ def print_version(ctx, param, value):
75
75
  @click.group()
76
76
  @click.option('--version', is_flag=True, callback=print_version,
77
77
  expose_value=False, is_eager=True, help="Show the version and exit.")
78
- def cli():
78
+ @click.option('--network', type=click.Choice(['testnet', 'mainnet']), default=None,
79
+ help="Override network for discovery filtering ('testnet' or 'mainnet')")
80
+ def cli(network: str | None):
79
81
  """VM on Golem management CLI"""
80
82
  ensure_config()
83
+ # Allow on-demand override without touching env
84
+ if network:
85
+ try:
86
+ config.network = network
87
+ except Exception:
88
+ pass
81
89
  pass
82
90
 
83
91
 
@@ -94,10 +102,14 @@ def vm():
94
102
  @click.option('--country', help='Preferred provider country')
95
103
  @click.option('--driver', type=click.Choice(['central', 'golem-base']), default=None, help='Discovery driver to use')
96
104
  @click.option('--json', 'as_json', is_flag=True, help='Output in JSON format')
105
+ @click.option('--network', type=click.Choice(['testnet', 'mainnet']), default=None,
106
+ help='Override network filter for this command')
97
107
  @async_command
98
- async def list_providers(cpu: Optional[int], memory: Optional[int], storage: Optional[int], country: Optional[str], driver: Optional[str], as_json: bool):
108
+ async def list_providers(cpu: Optional[int], memory: Optional[int], storage: Optional[int], country: Optional[str], driver: Optional[str], as_json: bool, network: Optional[str] = None):
99
109
  """List available providers matching requirements."""
100
110
  try:
111
+ if network:
112
+ config.network = network
101
113
  # Log search criteria if any
102
114
  if any([cpu, memory, storage, country]):
103
115
  logger.command("🔍 Searching for providers with criteria:")
@@ -112,7 +124,7 @@ async def list_providers(cpu: Optional[int], memory: Optional[int], storage: Opt
112
124
 
113
125
  # Determine the discovery driver being used
114
126
  discovery_driver = driver or config.discovery_driver
115
- logger.process(f"Querying discovery service via {discovery_driver}")
127
+ logger.process(f"Querying discovery service via {discovery_driver} (network={config.network})")
116
128
 
117
129
  # Initialize provider service
118
130
  provider_service = ProviderService()
@@ -173,11 +185,15 @@ async def list_providers(cpu: Optional[int], memory: Optional[int], storage: Opt
173
185
  @click.option('--cpu', type=int, required=True, help='Number of CPU cores')
174
186
  @click.option('--memory', type=int, required=True, help='Memory in GB')
175
187
  @click.option('--storage', type=int, required=True, help='Disk in GB')
188
+ @click.option('--stream-id', type=int, default=None, help='Optional StreamPayment stream id to fund this VM')
176
189
  @click.option('--yes', is_flag=True, help='Do not prompt for confirmation')
190
+ @click.option('--network', type=click.Choice(['testnet', 'mainnet']), default=None, help='Override network for discovery during creation')
177
191
  @async_command
178
- async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage: int, yes: bool):
192
+ async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage: int, stream_id: int | None, yes: bool, network: Optional[str] = None):
179
193
  """Create a new VM on a specific provider."""
180
194
  try:
195
+ if network:
196
+ config.network = network
181
197
  # Show configuration details
182
198
  click.echo("\n" + "─" * 60)
183
199
  click.echo(click.style(" VM Configuration", fg="blue", bold=True))
@@ -232,7 +248,8 @@ async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage:
232
248
  memory=memory,
233
249
  storage=storage,
234
250
  provider_ip=provider_ip,
235
- ssh_key=key_pair.public_key_content
251
+ ssh_key=key_pair.public_key_content,
252
+ stream_id=stream_id
236
253
  )
237
254
 
238
255
  # Get access info from config
@@ -281,6 +298,117 @@ async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage:
281
298
  raise click.Abort()
282
299
 
283
300
 
301
+ @vm.group(name='stream')
302
+ def vm_stream():
303
+ """Streaming payments helpers"""
304
+ pass
305
+
306
+
307
+ @vm_stream.command('open')
308
+ @click.option('--provider-id', required=True, help='Provider ID to use')
309
+ @click.option('--cpu', type=int, required=True, help='CPU cores for rate calc')
310
+ @click.option('--memory', type=int, required=True, help='Memory (GB) for rate calc')
311
+ @click.option('--storage', type=int, required=True, help='Storage (GB) for rate calc')
312
+ @click.option('--hours', type=int, default=1, help='Deposit coverage in hours (default 1)')
313
+ @click.option('--network', type=click.Choice(['testnet', 'mainnet']), default=None, help='Override network for discovery during stream open')
314
+ @async_command
315
+ async def stream_open(provider_id: str, cpu: int, memory: int, storage: int, hours: int, network: Optional[str] = None):
316
+ """Create a GLM stream for a planned VM rental."""
317
+ from ..payments.blockchain_service import StreamPaymentClient, StreamPaymentConfig
318
+ try:
319
+ if network:
320
+ config.network = network
321
+ provider_service = ProviderService()
322
+ async with provider_service:
323
+ provider = await provider_service.verify_provider(provider_id)
324
+ est = provider_service.compute_estimate(provider, (cpu, memory, storage))
325
+ if not est or est.get('glm_per_month') is None:
326
+ raise RequestorError('Provider does not advertise GLM pricing; cannot compute ratePerSecond')
327
+ glm_month = est['glm_per_month']
328
+ glm_per_second = float(glm_month) / (730.0 * 3600.0)
329
+ rate_per_second_wei = int(glm_per_second * (10**18))
330
+
331
+ provider_ip = 'localhost' if config.environment == "development" else provider.get('ip_address')
332
+ if not provider_ip and config.environment == "production":
333
+ raise RequestorError("Provider IP address not found in advertisement")
334
+ provider_url = config.get_provider_url(provider_ip)
335
+ async with ProviderClient(provider_url) as client:
336
+ info = await client.get_provider_info()
337
+ recipient = info['provider_id']
338
+
339
+ deposit_wei = rate_per_second_wei * int(hours) * 3600
340
+ # Prefer provider-advertised contract addresses to avoid mismatches
341
+ spc = StreamPaymentConfig(
342
+ rpc_url=config.polygon_rpc_url,
343
+ contract_address=info.get('stream_payment_address') or config.stream_payment_address,
344
+ glm_token_address=info.get('glm_token_address') or config.glm_token_address,
345
+ private_key=config.ethereum_private_key,
346
+ )
347
+ sp = StreamPaymentClient(spc)
348
+ stream_id = sp.create_stream(recipient, deposit_wei, rate_per_second_wei)
349
+ click.echo(json.dumps({"stream_id": stream_id, "rate_per_second_wei": rate_per_second_wei, "deposit_wei": deposit_wei}, indent=2))
350
+ except Exception as e:
351
+ logger.error(f"Failed to open stream: {e}")
352
+ raise click.Abort()
353
+
354
+
355
+ @vm_stream.command('topup')
356
+ @click.option('--stream-id', type=int, required=True)
357
+ @click.option('--glm', type=float, required=False, help='GLM amount to add')
358
+ @click.option('--hours', type=int, required=False, help='Hours of coverage to add at prior rate')
359
+ @async_command
360
+ async def stream_topup(stream_id: int, glm: float | None, hours: int | None):
361
+ """Top up a stream. Provide either --glm or --hours (using prior rate)."""
362
+ from ..payments.blockchain_service import StreamPaymentClient, StreamPaymentConfig
363
+ try:
364
+ spc = StreamPaymentConfig(
365
+ rpc_url=config.polygon_rpc_url,
366
+ contract_address=config.stream_payment_address,
367
+ glm_token_address=config.glm_token_address,
368
+ private_key=config.ethereum_private_key,
369
+ )
370
+ sp = StreamPaymentClient(spc)
371
+ add_wei: int
372
+ if glm is not None:
373
+ add_wei = int(float(glm) * (10**18))
374
+ elif hours is not None:
375
+ # naive: use last known rate by reading on-chain stream
376
+ rate = sp.contract.functions.streams(int(stream_id)).call()[5] # ratePerSecond
377
+ add_wei = int(rate) * int(hours) * 3600
378
+ else:
379
+ raise RequestorError('Provide either --glm or --hours')
380
+ tx = sp.top_up(stream_id, add_wei)
381
+ click.echo(json.dumps({"stream_id": stream_id, "topped_up_wei": add_wei, "tx": tx}, indent=2))
382
+ except Exception as e:
383
+ logger.error(f"Failed to top up stream: {e}")
384
+ raise click.Abort()
385
+
386
+
387
+ @cli.group()
388
+ def wallet():
389
+ """Wallet utilities (funding, balance)."""
390
+ pass
391
+
392
+
393
+ @wallet.command('faucet')
394
+ @async_command
395
+ async def wallet_faucet():
396
+ """Request L2 faucet funds for the requestor's payment address."""
397
+ try:
398
+ from ..security.faucet import L2FaucetService
399
+ from eth_account import Account
400
+ acct = Account.from_key(config.ethereum_private_key)
401
+ service = L2FaucetService(config)
402
+ tx = await service.request_funds(acct.address)
403
+ if tx:
404
+ click.echo(json.dumps({"address": acct.address, "tx": tx}, indent=2))
405
+ else:
406
+ click.echo(json.dumps({"address": acct.address, "tx": None}, indent=2))
407
+ except Exception as e:
408
+ logger.error(f"Faucet request failed: {e}")
409
+ raise click.Abort()
410
+
411
+
284
412
  @vm.command(name='ssh')
285
413
  @click.argument('name')
286
414
  @async_command
@@ -3,6 +3,7 @@ from typing import Optional, Dict
3
3
  import os
4
4
  from pydantic_settings import BaseSettings, SettingsConfigDict
5
5
  from pydantic import Field, field_validator, ValidationInfo
6
+ import os
6
7
 
7
8
 
8
9
  def ensure_config() -> None:
@@ -47,6 +48,11 @@ class RequestorConfig(BaseSettings):
47
48
  default="production",
48
49
  description="Environment mode: 'development' or 'production'"
49
50
  )
51
+ # Network (for discovery filtering and defaults)
52
+ network: str = Field(
53
+ default="mainnet",
54
+ description="Target network: 'testnet' or 'mainnet'"
55
+ )
50
56
 
51
57
  # Development Settings
52
58
  force_localhost: bool = Field(
@@ -94,6 +100,51 @@ class RequestorConfig(BaseSettings):
94
100
  description="Private key for Golem Base"
95
101
  )
96
102
 
103
+ # Payments (EVM RPC)
104
+ polygon_rpc_url: str = Field(
105
+ default="https://l2.holesky.golemdb.io/rpc",
106
+ description="EVM RPC URL for streaming payments (L2 by default)"
107
+ )
108
+ stream_payment_address: str = Field(
109
+ default="0x0000000000000000000000000000000000000000",
110
+ description="Deployed StreamPayment contract address"
111
+ )
112
+ glm_token_address: str = Field(
113
+ default="0x0000000000000000000000000000000000000000",
114
+ description="Token address (0x0 means native ETH)"
115
+ )
116
+ # Faucet settings (L2 payments)
117
+ l2_faucet_url: str = Field(
118
+ default="https://l2.holesky.golemdb.io/faucet",
119
+ description="L2 faucet base URL (no trailing /api)"
120
+ )
121
+ captcha_url: str = Field(
122
+ default="https://cap.gobas.me",
123
+ description="CAPTCHA base URL"
124
+ )
125
+ captcha_api_key: str = Field(
126
+ default="05381a2cef5e",
127
+ description="CAPTCHA API key path segment"
128
+ )
129
+ provider_eth_address: str = Field(
130
+ default="",
131
+ description="Optional provider Ethereum address for test/dev streaming"
132
+ )
133
+
134
+ @field_validator("polygon_rpc_url", mode='before')
135
+ @classmethod
136
+ def prefer_alt_env(cls, v: str) -> str:
137
+ # Accept alt aliases
138
+ for key in (
139
+ "GOLEM_REQUESTOR_l2_rpc_url",
140
+ "GOLEM_REQUESTOR_L2_RPC_URL",
141
+ "GOLEM_REQUESTOR_kaolin_rpc_url",
142
+ "GOLEM_REQUESTOR_KAOLIN_RPC_URL",
143
+ ):
144
+ if os.environ.get(key):
145
+ return os.environ[key]
146
+ return v
147
+
97
148
  # Base Directory
98
149
  base_dir: Path = Field(
99
150
  default_factory=lambda: Path.home() / ".golem" / "requestor",
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional, Any, Dict
5
+
6
+ from web3 import Web3
7
+ from eth_account import Account
8
+ from golem_streaming_abi import STREAM_PAYMENT_ABI, ERC20_ABI
9
+
10
+
11
+ @dataclass
12
+ class StreamPaymentConfig:
13
+ rpc_url: str
14
+ contract_address: str
15
+ glm_token_address: str
16
+ private_key: str
17
+
18
+
19
+ class StreamPaymentClient:
20
+ def __init__(self, cfg: StreamPaymentConfig):
21
+ self.web3 = Web3(Web3.HTTPProvider(cfg.rpc_url))
22
+ self.account = Account.from_key(cfg.private_key)
23
+ self.web3.eth.default_account = self.account.address
24
+
25
+ self.contract = self.web3.eth.contract(
26
+ address=Web3.to_checksum_address(cfg.contract_address), abi=STREAM_PAYMENT_ABI
27
+ )
28
+ self.token_address = Web3.to_checksum_address(cfg.glm_token_address)
29
+ self.native_eth = self.token_address.lower() == Web3.to_checksum_address("0x0000000000000000000000000000000000000000").lower()
30
+ self.erc20 = None
31
+ if not self.native_eth:
32
+ self.erc20 = self.web3.eth.contract(
33
+ address=self.token_address, abi=ERC20_ABI
34
+ )
35
+
36
+ def _send(self, fn) -> Dict[str, Any]:
37
+ base = {
38
+ "from": self.account.address,
39
+ "nonce": self.web3.eth.get_transaction_count(self.account.address),
40
+ }
41
+ # Fill chainId
42
+ try:
43
+ base["chainId"] = getattr(self.web3.eth, "chain_id", None) or self.web3.eth.chain_id
44
+ except Exception:
45
+ pass
46
+ # Try gas estimation and fee fields
47
+ try:
48
+ tx_preview = fn.build_transaction(base)
49
+ gas = self.web3.eth.estimate_gas(tx_preview)
50
+ base["gas"] = gas
51
+ except Exception:
52
+ pass
53
+ try:
54
+ # Prefer EIP-1559 if available
55
+ max_fee = getattr(self.web3.eth, "max_priority_fee", None)
56
+ if max_fee is not None:
57
+ base.setdefault("maxPriorityFeePerGas", max_fee)
58
+ base.setdefault("maxFeePerGas", getattr(self.web3.eth, "gas_price", lambda: None)() or self.web3.eth.gas_price)
59
+ except Exception:
60
+ try:
61
+ base.setdefault("gasPrice", self.web3.eth.gas_price)
62
+ except Exception:
63
+ pass
64
+
65
+ tx = fn.build_transaction(base)
66
+ # In production, sign and send raw; in tests, Account may be a dummy without signer
67
+ if hasattr(self.account, "sign_transaction"):
68
+ signed = self.account.sign_transaction(tx)
69
+ tx_hash = self.web3.eth.send_raw_transaction(signed.rawTransaction)
70
+ else:
71
+ tx_hash = self.web3.eth.send_transaction(tx)
72
+ receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash)
73
+ return {"transactionHash": tx_hash.hex(), "status": receipt.status, "logs": receipt.logs}
74
+
75
+ def create_stream(self, provider_address: str, deposit_wei: int, rate_per_second_wei: int) -> int:
76
+ tx_value = None
77
+ token_param = self.token_address if not self.native_eth else Web3.to_checksum_address("0x0000000000000000000000000000000000000000")
78
+
79
+ if not self.native_eth:
80
+ # Approve deposit for the StreamPayment contract (only if needed)
81
+ try:
82
+ allowance = self.erc20.functions.allowance(self.account.address, self.contract.address).call()
83
+ except Exception:
84
+ allowance = 0
85
+ if int(allowance) < int(deposit_wei):
86
+ approve = self.erc20.functions.approve(self.contract.address, int(deposit_wei))
87
+ self._send(approve)
88
+ else:
89
+ tx_value = int(deposit_wei)
90
+
91
+ # Create stream
92
+ fn = self.contract.functions.createStream(
93
+ token_param,
94
+ Web3.to_checksum_address(provider_address),
95
+ int(deposit_wei),
96
+ int(rate_per_second_wei),
97
+ )
98
+ # Include ETH value if native
99
+ if tx_value is not None:
100
+ # Build/send with value field
101
+ base = {
102
+ "from": self.account.address,
103
+ "nonce": self.web3.eth.get_transaction_count(self.account.address),
104
+ "value": tx_value,
105
+ }
106
+ tx = fn.build_transaction(base)
107
+ signed = self.account.sign_transaction(tx)
108
+ tx_hash = self.web3.eth.send_raw_transaction(signed.rawTransaction)
109
+ receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash)
110
+ tx_receipt = {"transactionHash": tx_hash.hex(), "status": receipt.status, "logs": receipt.logs}
111
+ else:
112
+ tx_receipt = self._send(fn)
113
+
114
+ # Try to parse StreamCreated event for streamId
115
+ try:
116
+ for log in tx_receipt["logs"]:
117
+ # very naive filter: topic0 = keccak256(StreamCreated(...))
118
+ # When ABI is attached to contract, use contract.events
119
+ ev = self.contract.events.StreamCreated().process_log(log)
120
+ return int(ev["args"]["streamId"])
121
+ except Exception:
122
+ pass
123
+ # As a fallback, cannot easily fetch return value from a tx; caller should query later
124
+ raise RuntimeError("create_stream: could not parse streamId from receipt")
125
+
126
+ def withdraw(self, stream_id: int) -> str:
127
+ fn = self.contract.functions.withdraw(int(stream_id))
128
+ receipt = self._send(fn)
129
+ return receipt["transactionHash"]
130
+
131
+ def terminate(self, stream_id: int) -> str:
132
+ fn = self.contract.functions.terminate(int(stream_id))
133
+ receipt = self._send(fn)
134
+ return receipt["transactionHash"]
135
+
136
+ def top_up(self, stream_id: int, amount_wei: int) -> str:
137
+ if not self.native_eth:
138
+ # Approve first (only if needed)
139
+ try:
140
+ allowance = self.erc20.functions.allowance(self.account.address, self.contract.address).call()
141
+ except Exception:
142
+ allowance = 0
143
+ if int(allowance) < int(amount_wei):
144
+ approve = self.erc20.functions.approve(self.contract.address, int(amount_wei))
145
+ self._send(approve)
146
+ fn = self.contract.functions.topUp(int(stream_id), int(amount_wei))
147
+ receipt = self._send(fn)
148
+ return receipt["transactionHash"]
149
+ else:
150
+ # Native ETH mode: send value along with call
151
+ fn = self.contract.functions.topUp(int(stream_id), int(amount_wei))
152
+ base = {
153
+ "from": self.account.address,
154
+ "nonce": self.web3.eth.get_transaction_count(self.account.address),
155
+ "value": int(amount_wei),
156
+ }
157
+ tx = fn.build_transaction(base)
158
+ signed = self.account.sign_transaction(tx)
159
+ tx_hash = self.web3.eth.send_raw_transaction(signed.rawTransaction)
160
+ self.web3.eth.wait_for_transaction_receipt(tx_hash)
161
+ return tx_hash.hex()
@@ -21,26 +21,37 @@ class ProviderClient:
21
21
  cpu: int,
22
22
  memory: int,
23
23
  storage: int,
24
- ssh_key: str
24
+ ssh_key: str,
25
+ stream_id: int | None = None,
25
26
  ) -> Dict:
26
27
  """Create a VM on the provider."""
28
+ payload = {
29
+ "name": name,
30
+ "resources": {
31
+ "cpu": cpu,
32
+ "memory": memory,
33
+ "storage": storage
34
+ },
35
+ "ssh_key": ssh_key
36
+ }
37
+ if stream_id is not None:
38
+ payload["stream_id"] = int(stream_id)
27
39
  async with self.session.post(
28
40
  f"{self.provider_url}/api/v1/vms",
29
- json={
30
- "name": name,
31
- "resources": {
32
- "cpu": cpu,
33
- "memory": memory,
34
- "storage": storage
35
- },
36
- "ssh_key": ssh_key
37
- }
41
+ json=payload
38
42
  ) as response:
39
43
  if not response.ok:
40
44
  error_text = await response.text()
41
45
  raise Exception(f"Failed to create VM: {error_text}")
42
46
  return await response.json()
43
47
 
48
+ async def get_provider_info(self) -> Dict:
49
+ async with self.session.get(f"{self.provider_url}/api/v1/provider/info") as response:
50
+ if not response.ok:
51
+ error_text = await response.text()
52
+ raise Exception(f"Failed to fetch provider info: {error_text}")
53
+ return await response.json()
54
+
44
55
  async def add_ssh_key(self, vm_id: str, key: str) -> None:
45
56
  """Add SSH key to VM."""
46
57
  async with self.session.post(
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional, List, Tuple
4
+
5
+ from web3 import Web3
6
+ from golem_faucet import PowFaucetClient
7
+ from ..utils.logging import setup_logger
8
+ from ..config import RequestorConfig
9
+
10
+ logger = setup_logger(__name__)
11
+
12
+
13
+ class L2FaucetService:
14
+ def __init__(self, config: RequestorConfig):
15
+ self.cfg = config
16
+ self.web3 = Web3(Web3.HTTPProvider(config.polygon_rpc_url))
17
+ self.client = PowFaucetClient(
18
+ faucet_url=getattr(config, 'l2_faucet_url', 'https://l2.holesky.golemdb.io/faucet'),
19
+ captcha_base_url=getattr(config, 'captcha_url', 'https://cap.gobas.me'),
20
+ captcha_api_key=getattr(config, 'captcha_api_key', '05381a2cef5e'),
21
+ )
22
+
23
+ def _balance_eth(self, address: str) -> float:
24
+ try:
25
+ wei = self.web3.eth.get_balance(Web3.to_checksum_address(address))
26
+ return float(self.web3.from_wei(wei, 'ether'))
27
+ except Exception as e:
28
+ logger.warning(f"balance check failed: {e}")
29
+ return 0.0
30
+
31
+ async def request_funds(self, address: str) -> Optional[str]:
32
+ bal = self._balance_eth(address)
33
+ if bal > 0.01:
34
+ logger.info(f"Sufficient L2 funds ({bal} ETH), skipping faucet.")
35
+ return None
36
+ chall = await self.client.get_challenge()
37
+ if not chall:
38
+ logger.error("could not fetch faucet challenge")
39
+ return None
40
+ token = chall.get('token')
41
+ challenge_list = chall.get('challenge') or []
42
+ solutions: List[Tuple[str, str, int]] = []
43
+ for salt, target in challenge_list:
44
+ nonce = PowFaucetClient.solve_challenge(salt, target)
45
+ solutions.append((salt, target, nonce))
46
+ redeemed = await self.client.redeem(token, solutions)
47
+ if not redeemed:
48
+ logger.error("failed to redeem challenge")
49
+ return None
50
+ tx = await self.client.request_funds(address, redeemed)
51
+ if tx:
52
+ logger.success(f"L2 faucet sent tx: {tx}")
53
+ return tx
54
+
@@ -93,6 +93,9 @@ class ProviderService:
93
93
  """Find providers using Golem Base."""
94
94
  try:
95
95
  query = 'golem_type="provider"'
96
+ # Filter by advertised network to avoid cross-network results
97
+ if config.network:
98
+ query += f' && golem_network="{config.network}"'
96
99
  if cpu:
97
100
  query += f' && golem_cpu>={cpu}'
98
101
  if memory:
@@ -14,11 +14,13 @@ class VMService:
14
14
  self,
15
15
  db_service: DatabaseService,
16
16
  ssh_service: SSHService,
17
- provider_client: Optional[ProviderClient] = None
17
+ provider_client: Optional[ProviderClient] = None,
18
+ blockchain_client: Optional[object] = None,
18
19
  ):
19
20
  self.db = db_service
20
21
  self.ssh_service = ssh_service
21
22
  self.provider_client = provider_client
23
+ self.blockchain_client = blockchain_client
22
24
 
23
25
  async def create_vm(
24
26
  self,
@@ -27,7 +29,8 @@ class VMService:
27
29
  memory: int,
28
30
  storage: int,
29
31
  provider_ip: str,
30
- ssh_key: str
32
+ ssh_key: str,
33
+ stream_id: int | None = None,
31
34
  ) -> Dict:
32
35
  """Create a new VM with validation and error handling."""
33
36
  try:
@@ -42,18 +45,23 @@ class VMService:
42
45
  cpu=cpu,
43
46
  memory=memory,
44
47
  storage=storage,
45
- ssh_key=ssh_key
48
+ ssh_key=ssh_key,
49
+ stream_id=stream_id
46
50
  )
47
51
 
48
52
  # Get VM access info
49
53
  access_info = await self.provider_client.get_vm_access(vm['id'])
50
54
 
55
+ # Preserve any provided stream_id; do not auto-create streams here
56
+ # Stream creation should be explicit via CLI `vm stream open` command.
57
+
51
58
  # Save VM details to database
52
59
  config = {
53
60
  'cpu': cpu,
54
61
  'memory': memory,
55
62
  'storage': storage,
56
- 'ssh_port': access_info['ssh_port']
63
+ 'ssh_port': access_info['ssh_port'],
64
+ **({"stream_id": stream_id} if stream_id is not None else {}),
57
65
  }
58
66
  await self.db.save_vm(
59
67
  name=name,
@@ -88,6 +96,15 @@ class VMService:
88
96
  if "Not Found" not in str(e):
89
97
  raise
90
98
 
99
+ # Attempt to terminate stream if present
100
+ try:
101
+ stream_id = vm.get('config', {}).get('stream_id')
102
+ if stream_id is not None and self.blockchain_client:
103
+ self.blockchain_client.terminate(stream_id)
104
+ except Exception:
105
+ # Best-effort: do not block deletion on chain failure
106
+ pass
107
+
91
108
  # Remove from database
92
109
  await self.db.delete_vm(name)
93
110
 
@@ -125,6 +142,14 @@ class VMService:
125
142
  # Update status in database
126
143
  await self.db.update_vm_status(name, "stopped")
127
144
 
145
+ # Best-effort withdraw on stop
146
+ try:
147
+ stream_id = vm.get('config', {}).get('stream_id')
148
+ if stream_id is not None and self.blockchain_client:
149
+ self.blockchain_client.withdraw(stream_id)
150
+ except Exception:
151
+ pass
152
+
128
153
  except Exception as e:
129
154
  raise VMError(f"Failed to stop VM: {str(e)}")
130
155