prismnetwork 0.3.0__tar.gz → 0.3.2__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.
- {prismnetwork-0.3.0 → prismnetwork-0.3.2}/PKG-INFO +54 -4
- prismnetwork-0.3.2/README.md +106 -0
- {prismnetwork-0.3.0 → prismnetwork-0.3.2}/examples/quickstart.py +1 -1
- {prismnetwork-0.3.0 → prismnetwork-0.3.2}/prismnetwork/__init__.py +7 -1
- {prismnetwork-0.3.0 → prismnetwork-0.3.2}/prismnetwork/_agent.py +152 -10
- prismnetwork-0.3.2/prismnetwork/_x402.py +48 -0
- {prismnetwork-0.3.0 → prismnetwork-0.3.2}/prismnetwork/toolkit.py +1 -1
- {prismnetwork-0.3.0 → prismnetwork-0.3.2}/pyproject.toml +1 -1
- prismnetwork-0.3.2/tests/__init__.py +0 -0
- prismnetwork-0.3.2/tests/test_hostkey.py +128 -0
- prismnetwork-0.3.2/tests/test_x402.py +99 -0
- prismnetwork-0.3.0/README.md +0 -56
- {prismnetwork-0.3.0 → prismnetwork-0.3.2}/.gitignore +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: prismnetwork
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Headless GPU leasing on Prism Network for autonomous agents. Wallet-signature auth, on-chain USDG payment, SSH.
|
|
5
5
|
Project-URL: Homepage, https://prismnetwork.tech
|
|
6
6
|
Project-URL: Source, https://github.com/prismnetwork-tech/prism
|
|
@@ -26,7 +26,7 @@ pip install prismnetwork
|
|
|
26
26
|
```python
|
|
27
27
|
from prismnetwork import PrismAgent, DEFAULT_IMAGE
|
|
28
28
|
|
|
29
|
-
agent = PrismAgent(private_key=AGENT_KEY, escrow="
|
|
29
|
+
agent = PrismAgent(private_key=AGENT_KEY, escrow="0xfD4228eEEfC49e4b76A0CD40af9fdd546220B2FD")
|
|
30
30
|
agent.authenticate()
|
|
31
31
|
|
|
32
32
|
lease = agent.lease(image=DEFAULT_IMAGE, duration_seconds=600, min_vram_mib=16000)
|
|
@@ -45,6 +45,39 @@ per-second; each lease settles on-chain with a verifiable receipt.
|
|
|
45
45
|
|
|
46
46
|
Read-only helpers: `offers()`, `balances()`, `leases()`, `quote(...)`, `access(id)`.
|
|
47
47
|
|
|
48
|
+
## Which machine answered
|
|
49
|
+
|
|
50
|
+
Every session `run()` opens checks the SSH host key on the far end, and
|
|
51
|
+
`host_key_policy(lease.access)` says what that check was worth:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from prismnetwork import host_key_policy
|
|
55
|
+
|
|
56
|
+
host_key_policy(lease.access)
|
|
57
|
+
# {"mode": "attested" | "reported" | "unverified", "fingerprint": ..., "source": ...}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`attested` means the fingerprint comes out of a hardware report whose signed data
|
|
61
|
+
commits to the key the guest generated at boot. Prism walks that report and puts
|
|
62
|
+
the fingerprint on the access grant, and the SDK refuses a session whose host key
|
|
63
|
+
does not match it, so the operator cannot put a different machine on the other
|
|
64
|
+
end. The report is checked in our control plane rather than here, so `attested`
|
|
65
|
+
says we checked it and this client holds the session to what we published.
|
|
66
|
+
|
|
67
|
+
`reported` means the node named the key on the signed report that opened
|
|
68
|
+
access, which rules out the relay and the network path but not the operator,
|
|
69
|
+
whose bond is what a dispute reaches instead.
|
|
70
|
+
|
|
71
|
+
`unverified` means nobody published a key: capacity brokered from a public
|
|
72
|
+
cloud has its host key generated by the cloud and never shown to the network, so
|
|
73
|
+
the key is recorded on first sight and held for the rest of the lease.
|
|
74
|
+
|
|
75
|
+
Your own `~/.ssh/known_hosts` is never touched. The record sits beside the
|
|
76
|
+
lease's private key and is removed with it by `end_lease()`.
|
|
77
|
+
|
|
78
|
+
`PrismAgent(..., require_host_key=True)` refuses to open a session on a lease
|
|
79
|
+
that publishes no key, rather than trusting whichever machine answers first.
|
|
80
|
+
|
|
48
81
|
## Batch
|
|
49
82
|
|
|
50
83
|
Pass `command=` to `lease()` and the node runs that one command and reports its
|
|
@@ -60,10 +93,27 @@ broker path has no signed result channel. Commands are capped at 8 KiB and outpu
|
|
|
60
93
|
at 64 KiB per stream. `result(lease_id)` and `wait_for_result(lease_id)` read the
|
|
61
94
|
output of an already-funded batch lease.
|
|
62
95
|
|
|
96
|
+
## Paying an x402 endpoint
|
|
97
|
+
|
|
98
|
+
Prism's pay-per-call endpoints take a transfer plus a signature over what the
|
|
99
|
+
transfer buys, so a payment can only be redeemed against the request it was made
|
|
100
|
+
for. `payment_header` builds the `X-PAYMENT` value:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from prismnetwork import payment_header
|
|
104
|
+
|
|
105
|
+
body = json.dumps({"model": "llama3.2:3b", "prompt": "what is a prism"}).encode()
|
|
106
|
+
header = payment_header(agent.account, tx_hash, body)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Pass the bytes the request actually carries. `bound_message` and `hash_request`
|
|
110
|
+
are exported for a client that builds the envelope itself; they produce the same
|
|
111
|
+
message the Node SDK signs.
|
|
112
|
+
|
|
63
113
|
The wallet needs USDG (`0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168`, 6 decimals) and
|
|
64
114
|
native Robinhood-Chain gas.
|
|
65
115
|
|
|
66
|
-
Requires `ssh` and `ssh-
|
|
116
|
+
Requires `ssh`, `ssh-keygen` and `ssh-keyscan` on `PATH`. Chain id 4663, RPC
|
|
67
117
|
`https://rpc.mainnet.chain.robinhood.com`.
|
|
68
118
|
|
|
69
119
|
Prism is pre-production and unaudited. A permissionless supplier is not a trusted
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# prismnetwork
|
|
2
|
+
|
|
3
|
+
Headless GPU leasing on [Prism Network](https://prismnetwork.tech) for autonomous
|
|
4
|
+
agents, the Python counterpart to `@prismnetwork/agent-sdk`. Give it a wallet; it
|
|
5
|
+
authenticates with a signature, pays on-chain in USDG, provisions a GPU, and runs
|
|
6
|
+
over SSH. No browser, no dashboard.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
pip install prismnetwork
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from prismnetwork import PrismAgent, DEFAULT_IMAGE
|
|
14
|
+
|
|
15
|
+
agent = PrismAgent(private_key=AGENT_KEY, escrow="0xfD4228eEEfC49e4b76A0CD40af9fdd546220B2FD")
|
|
16
|
+
agent.authenticate()
|
|
17
|
+
|
|
18
|
+
lease = agent.lease(image=DEFAULT_IMAGE, duration_seconds=600, min_vram_mib=16000)
|
|
19
|
+
out = agent.run(lease, "nvidia-smi")
|
|
20
|
+
print(out["stdout"])
|
|
21
|
+
agent.end_lease(lease)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## What it does
|
|
25
|
+
|
|
26
|
+
`authenticate()` signs a challenge with the wallet and exchanges it for a bearer
|
|
27
|
+
session. `lease()` gets a quote, funds an on-chain USDG escrow bound to the quote
|
|
28
|
+
(`createLease`), waits for the GPU to provision, and returns SSH access. `run()`
|
|
29
|
+
executes a command over SSH, retrying through the host's sshd warmup. Metering is
|
|
30
|
+
per-second; each lease settles on-chain with a verifiable receipt.
|
|
31
|
+
|
|
32
|
+
Read-only helpers: `offers()`, `balances()`, `leases()`, `quote(...)`, `access(id)`.
|
|
33
|
+
|
|
34
|
+
## Which machine answered
|
|
35
|
+
|
|
36
|
+
Every session `run()` opens checks the SSH host key on the far end, and
|
|
37
|
+
`host_key_policy(lease.access)` says what that check was worth:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from prismnetwork import host_key_policy
|
|
41
|
+
|
|
42
|
+
host_key_policy(lease.access)
|
|
43
|
+
# {"mode": "attested" | "reported" | "unverified", "fingerprint": ..., "source": ...}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`attested` means the fingerprint comes out of a hardware report whose signed data
|
|
47
|
+
commits to the key the guest generated at boot. Prism walks that report and puts
|
|
48
|
+
the fingerprint on the access grant, and the SDK refuses a session whose host key
|
|
49
|
+
does not match it, so the operator cannot put a different machine on the other
|
|
50
|
+
end. The report is checked in our control plane rather than here, so `attested`
|
|
51
|
+
says we checked it and this client holds the session to what we published.
|
|
52
|
+
|
|
53
|
+
`reported` means the node named the key on the signed report that opened
|
|
54
|
+
access, which rules out the relay and the network path but not the operator,
|
|
55
|
+
whose bond is what a dispute reaches instead.
|
|
56
|
+
|
|
57
|
+
`unverified` means nobody published a key: capacity brokered from a public
|
|
58
|
+
cloud has its host key generated by the cloud and never shown to the network, so
|
|
59
|
+
the key is recorded on first sight and held for the rest of the lease.
|
|
60
|
+
|
|
61
|
+
Your own `~/.ssh/known_hosts` is never touched. The record sits beside the
|
|
62
|
+
lease's private key and is removed with it by `end_lease()`.
|
|
63
|
+
|
|
64
|
+
`PrismAgent(..., require_host_key=True)` refuses to open a session on a lease
|
|
65
|
+
that publishes no key, rather than trusting whichever machine answers first.
|
|
66
|
+
|
|
67
|
+
## Batch
|
|
68
|
+
|
|
69
|
+
Pass `command=` to `lease()` and the node runs that one command and reports its
|
|
70
|
+
output instead of granting SSH access:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
batch = agent.lease(image=DEFAULT_IMAGE, duration_seconds=600, command="nvidia-smi")
|
|
74
|
+
print(batch.result["stdout"])
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Batch leases match only suppliers at trust class `isolated` or above, because the
|
|
78
|
+
broker path has no signed result channel. Commands are capped at 8 KiB and output
|
|
79
|
+
at 64 KiB per stream. `result(lease_id)` and `wait_for_result(lease_id)` read the
|
|
80
|
+
output of an already-funded batch lease.
|
|
81
|
+
|
|
82
|
+
## Paying an x402 endpoint
|
|
83
|
+
|
|
84
|
+
Prism's pay-per-call endpoints take a transfer plus a signature over what the
|
|
85
|
+
transfer buys, so a payment can only be redeemed against the request it was made
|
|
86
|
+
for. `payment_header` builds the `X-PAYMENT` value:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from prismnetwork import payment_header
|
|
90
|
+
|
|
91
|
+
body = json.dumps({"model": "llama3.2:3b", "prompt": "what is a prism"}).encode()
|
|
92
|
+
header = payment_header(agent.account, tx_hash, body)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Pass the bytes the request actually carries. `bound_message` and `hash_request`
|
|
96
|
+
are exported for a client that builds the envelope itself; they produce the same
|
|
97
|
+
message the Node SDK signs.
|
|
98
|
+
|
|
99
|
+
The wallet needs USDG (`0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168`, 6 decimals) and
|
|
100
|
+
native Robinhood-Chain gas.
|
|
101
|
+
|
|
102
|
+
Requires `ssh`, `ssh-keygen` and `ssh-keyscan` on `PATH`. Chain id 4663, RPC
|
|
103
|
+
`https://rpc.mainnet.chain.robinhood.com`.
|
|
104
|
+
|
|
105
|
+
Prism is pre-production and unaudited. A permissionless supplier is not a trusted
|
|
106
|
+
computing environment; do not lease with a wallet or workload you cannot lose.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Authenticate a wallet, list GPUs, and optionally lease one and run a command.
|
|
2
2
|
|
|
3
3
|
PRISM_AGENT_KEY=0x<agent wallet private key> \\
|
|
4
|
-
PRISM_ESCROW=
|
|
4
|
+
PRISM_ESCROW=0xfD4228eEEfC49e4b76A0CD40af9fdd546220B2FD \\
|
|
5
5
|
python quickstart.py
|
|
6
6
|
|
|
7
7
|
By default this authenticates and lists GPUs without spending. Set PRISM_RUN_LEASE=1
|
|
@@ -6,8 +6,10 @@ from ._agent import (
|
|
|
6
6
|
Lease,
|
|
7
7
|
PrismAgent,
|
|
8
8
|
PrismError,
|
|
9
|
+
host_key_policy,
|
|
9
10
|
)
|
|
10
11
|
|
|
12
|
+
from ._x402 import bound_message, hash_request, payment_header
|
|
11
13
|
from .toolkit import DEFAULT_ESCROW, PrismToolset, agent_from_env
|
|
12
14
|
|
|
13
15
|
__all__ = [
|
|
@@ -21,5 +23,9 @@ __all__ = [
|
|
|
21
23
|
"DEFAULT_IMAGE",
|
|
22
24
|
"TRUST_CLASSES",
|
|
23
25
|
"USDG",
|
|
26
|
+
"host_key_policy",
|
|
27
|
+
"bound_message",
|
|
28
|
+
"hash_request",
|
|
29
|
+
"payment_header",
|
|
24
30
|
]
|
|
25
|
-
__version__ = "0.3.
|
|
31
|
+
__version__ = "0.3.2"
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
3
6
|
import re
|
|
4
7
|
import shutil
|
|
5
8
|
import subprocess
|
|
@@ -101,7 +104,8 @@ class PrismAgent:
|
|
|
101
104
|
signature, pay on-chain in USDG, provision, and run over SSH."""
|
|
102
105
|
|
|
103
106
|
def __init__(self, private_key: str, escrow: str,
|
|
104
|
-
api_base: str = "https://prismnetwork.tech", rpc_url: str = ROBINHOOD_RPC
|
|
107
|
+
api_base: str = "https://prismnetwork.tech", rpc_url: str = ROBINHOOD_RPC,
|
|
108
|
+
require_host_key: bool = False):
|
|
105
109
|
if not escrow:
|
|
106
110
|
raise ValueError("escrow address is required")
|
|
107
111
|
if not isinstance(private_key, str) or not private_key.strip():
|
|
@@ -118,6 +122,11 @@ class PrismAgent:
|
|
|
118
122
|
self._usdg = self.w3.eth.contract(address=USDG, abi=_ERC20)
|
|
119
123
|
self._escrow = self.w3.eth.contract(address=self.escrow, abi=_ESCROW)
|
|
120
124
|
self.session: str | None = None
|
|
125
|
+
# Off by default because most capacity publishes no host key, and
|
|
126
|
+
# refusing those leases would take the network's own supply away from
|
|
127
|
+
# callers who never asked for the guarantee. On, nothing runs anywhere
|
|
128
|
+
# the grant cannot name.
|
|
129
|
+
self.require_host_key = require_host_key
|
|
121
130
|
|
|
122
131
|
@property
|
|
123
132
|
def address(self) -> str:
|
|
@@ -288,18 +297,28 @@ class PrismAgent:
|
|
|
288
297
|
if not a.get("ssh_host") or not a.get("ssh_port"):
|
|
289
298
|
raise PrismError(400, "ssh_access_unavailable",
|
|
290
299
|
{"mode": a.get("mode"), "lease_id": lease.lease_id})
|
|
291
|
-
args = ["ssh", "-i", lease.key_path, "-p", str(a["ssh_port"]),
|
|
292
|
-
"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
|
|
293
|
-
"-o", "BatchMode=yes", "-o", "ConnectTimeout=15",
|
|
294
|
-
f"{a.get('ssh_user', 'root')}@{a['ssh_host']}", command]
|
|
295
300
|
last = None
|
|
296
301
|
for attempt in range(connect_retries + 1):
|
|
297
302
|
try:
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
+
host_key = _host_key_args(a["ssh_host"], a["ssh_port"], lease.key_path, a,
|
|
304
|
+
self.require_host_key)
|
|
305
|
+
except PrismError as e:
|
|
306
|
+
# A box that is still coming up has nothing listening to read a
|
|
307
|
+
# key from, which is the same wait this loop already exists for.
|
|
308
|
+
# Being answered by the wrong machine is not a wait.
|
|
309
|
+
if e.code != "host_key_unavailable":
|
|
310
|
+
raise
|
|
311
|
+
res = {"code": 255, "stdout": "", "stderr": f"ssh: {e.code}"}
|
|
312
|
+
else:
|
|
313
|
+
args = ["ssh", "-i", lease.key_path, "-p", str(a["ssh_port"]), *host_key,
|
|
314
|
+
"-o", "BatchMode=yes", "-o", "ConnectTimeout=15",
|
|
315
|
+
f"{a.get('ssh_user', 'root')}@{a['ssh_host']}", command]
|
|
316
|
+
try:
|
|
317
|
+
p = subprocess.run(args, capture_output=True, text=True, timeout=timeout + 20,
|
|
318
|
+
input=stdin if stdin is not None else "")
|
|
319
|
+
res = {"code": p.returncode, "stdout": p.stdout.strip(), "stderr": p.stderr.strip()}
|
|
320
|
+
except subprocess.TimeoutExpired:
|
|
321
|
+
res = {"code": -1, "stdout": "", "stderr": "timed out"}
|
|
303
322
|
if not _is_ssh_warmup(res):
|
|
304
323
|
return res
|
|
305
324
|
last = res
|
|
@@ -401,6 +420,129 @@ def _safe_json(res):
|
|
|
401
420
|
return None
|
|
402
421
|
|
|
403
422
|
|
|
423
|
+
# Checking which machine answered.
|
|
424
|
+
#
|
|
425
|
+
# A lease hands the renter an address and a private key. Until the host key on
|
|
426
|
+
# the other end is checked, anything that can reach that address can take the
|
|
427
|
+
# session, read the work and answer as if it were the GPU. What the network can
|
|
428
|
+
# say about that key differs by where the capacity came from, so the decision is
|
|
429
|
+
# made here rather than defaulted: a grant that names a fingerprint is checked
|
|
430
|
+
# before the session opens, and a grant that names none has its key recorded on
|
|
431
|
+
# first sight and held for the rest of the lease. The record lives beside the
|
|
432
|
+
# lease's private key and goes when the lease does; the caller's own
|
|
433
|
+
# ~/.ssh/known_hosts is never touched.
|
|
434
|
+
|
|
435
|
+
_SCAN_TIMEOUT = 10
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def host_key_policy(access: dict | None) -> dict:
|
|
439
|
+
"""What the network is willing to say about the machine behind a grant.
|
|
440
|
+
|
|
441
|
+
``attested`` is the only one that survives a hostile operator: the
|
|
442
|
+
fingerprint comes out of a report the processor signed. ``reported`` is the
|
|
443
|
+
operator's word under their bonded device key, which rules out everyone
|
|
444
|
+
between them and the renter. ``unverified`` means nobody published a key and
|
|
445
|
+
the first connection decides.
|
|
446
|
+
"""
|
|
447
|
+
fingerprint = (access or {}).get("channel_key_fingerprint")
|
|
448
|
+
if not fingerprint:
|
|
449
|
+
return {"mode": "unverified", "fingerprint": None, "source": None}
|
|
450
|
+
source = access.get("channel_key_source")
|
|
451
|
+
return {
|
|
452
|
+
"mode": "attested" if source == "snp_report" else "reported",
|
|
453
|
+
"fingerprint": fingerprint,
|
|
454
|
+
"source": source,
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _known_hosts_path(key_path: str) -> str:
|
|
459
|
+
return os.path.join(os.path.dirname(key_path), "known_hosts")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _known_hosts_fingerprint(line: str) -> str | None:
|
|
463
|
+
"""The ``ssh-keygen -lf`` form of the key in a known_hosts line. The control
|
|
464
|
+
plane publishes fingerprints in exactly this form, so the two are compared
|
|
465
|
+
as strings."""
|
|
466
|
+
fields = line.strip().split()
|
|
467
|
+
if len(fields) < 3:
|
|
468
|
+
return None
|
|
469
|
+
try:
|
|
470
|
+
raw = base64.b64decode(fields[2], validate=True)
|
|
471
|
+
except Exception:
|
|
472
|
+
return None
|
|
473
|
+
if not raw:
|
|
474
|
+
return None
|
|
475
|
+
return "SHA256:" + base64.b64encode(hashlib.sha256(raw).digest()).decode().rstrip("=")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _host_field(host: str, port) -> str:
|
|
479
|
+
return str(host) if int(port) == 22 else f"[{host}]:{port}"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _already_pinned(path: str, host: str, port, fingerprint: str) -> bool:
|
|
483
|
+
# The relay hands out a fresh local port for every session, so a record that
|
|
484
|
+
# names the right key under the wrong address is a record ssh will refuse to
|
|
485
|
+
# use. Both halves have to match for the scan to be worth skipping.
|
|
486
|
+
try:
|
|
487
|
+
with open(path) as handle:
|
|
488
|
+
lines = handle.read().splitlines()
|
|
489
|
+
except OSError:
|
|
490
|
+
return False
|
|
491
|
+
return any(line.split()[:1] == [_host_field(host, port)]
|
|
492
|
+
and _known_hosts_fingerprint(line) == fingerprint for line in lines if line.strip())
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _pin_host_key(host: str, port, fingerprint: str, path: str) -> None:
|
|
496
|
+
"""Read the host key off the wire and record it only if it is the one the
|
|
497
|
+
grant named.
|
|
498
|
+
|
|
499
|
+
Done as a separate exchange before ssh runs, because a fingerprint cannot be
|
|
500
|
+
turned into a known_hosts entry without the key itself, and letting ssh learn
|
|
501
|
+
the key first would mean trusting it to find out whether it should have.
|
|
502
|
+
``ssh-keyscan`` reads the key the server offers and hangs up, so nothing the
|
|
503
|
+
machine could use is sent.
|
|
504
|
+
"""
|
|
505
|
+
if _already_pinned(path, host, port, fingerprint):
|
|
506
|
+
return
|
|
507
|
+
try:
|
|
508
|
+
scan = subprocess.run(["ssh-keyscan", "-T", str(_SCAN_TIMEOUT), "-p", str(port), str(host)],
|
|
509
|
+
capture_output=True, text=True, timeout=_SCAN_TIMEOUT + 5)
|
|
510
|
+
offered = [l for l in scan.stdout.splitlines() if l and not l.startswith("#")]
|
|
511
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
512
|
+
raise PrismError(503, "host_key_unavailable", {"host": host, "port": port, "cause": str(e)}) from e
|
|
513
|
+
if not offered:
|
|
514
|
+
raise PrismError(503, "host_key_unavailable",
|
|
515
|
+
{"host": host, "port": port, "cause": (scan.stderr or "").strip()[:200]})
|
|
516
|
+
for line in offered:
|
|
517
|
+
if _known_hosts_fingerprint(line) == fingerprint:
|
|
518
|
+
# Only the key that matched. Writing everything the machine offered
|
|
519
|
+
# would pin keys nobody vouched for alongside the one that was
|
|
520
|
+
# checked.
|
|
521
|
+
with open(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as handle:
|
|
522
|
+
handle.write(line + "\n")
|
|
523
|
+
return
|
|
524
|
+
raise PrismError(502, "host_key_mismatch", {
|
|
525
|
+
"expected": fingerprint,
|
|
526
|
+
"offered": [f for f in (_known_hosts_fingerprint(l) for l in offered) if f],
|
|
527
|
+
"hint": "the machine answering is not the one the lease names; nothing was sent to it",
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _host_key_args(host, port, key_path: str, access: dict | None, require_host_key: bool) -> list:
|
|
532
|
+
"""The ssh options that make the connection check the machine it reaches."""
|
|
533
|
+
path = _known_hosts_path(key_path)
|
|
534
|
+
policy = host_key_policy(access)
|
|
535
|
+
if policy["fingerprint"] is None:
|
|
536
|
+
if require_host_key:
|
|
537
|
+
raise PrismError(400, "host_key_unpublished", {
|
|
538
|
+
"mode": (access or {}).get("mode"),
|
|
539
|
+
"hint": "this lease publishes no host key, so which machine answers cannot be checked",
|
|
540
|
+
})
|
|
541
|
+
return ["-o", f"UserKnownHostsFile={path}", "-o", "StrictHostKeyChecking=accept-new"]
|
|
542
|
+
_pin_host_key(host, port, policy["fingerprint"], path)
|
|
543
|
+
return ["-o", f"UserKnownHostsFile={path}", "-o", "StrictHostKeyChecking=yes"]
|
|
544
|
+
|
|
545
|
+
|
|
404
546
|
def _is_ssh_warmup(res: dict) -> bool:
|
|
405
547
|
if res["code"] != 255:
|
|
406
548
|
return False
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""What a payer signs on Prism's legacy x402 rail.
|
|
2
|
+
|
|
3
|
+
The transfer is already on chain by the time the header is sent, so the header
|
|
4
|
+
only says who made it. Signing the transaction hash alone proves who paid and
|
|
5
|
+
not what for: anyone who read the header in flight could put their own command
|
|
6
|
+
in front of it and spend someone else's transfer. The request travels inside the
|
|
7
|
+
signed message, and the endpoint checks it against the request that arrived.
|
|
8
|
+
|
|
9
|
+
This is the construction the Node SDK signs in ``sdk/x402.mjs``, and
|
|
10
|
+
``tests/test_x402.py`` holds the two against each other.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
|
|
19
|
+
from eth_account.messages import encode_defunct
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def hash_request(payload) -> str:
|
|
23
|
+
"""The digest both sides compare: the command for a job, the request bytes
|
|
24
|
+
for a generation. Text is hashed as UTF-8, which is how it goes on the wire."""
|
|
25
|
+
data = payload.encode("utf-8") if isinstance(payload, str) else bytes(payload)
|
|
26
|
+
return hashlib.sha256(data).hexdigest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def bound_message(tx_hash: str, request_hash: str) -> str:
|
|
30
|
+
return f"prism-x402:v2\n{str(tx_hash).lower()}\n{request_hash}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def payment_header(account, tx_hash: str, body, network: str | None = None) -> str:
|
|
34
|
+
"""The ``X-PAYMENT`` value for one paid request: the transfer that funded it,
|
|
35
|
+
and a signature over that transfer together with the exact bytes being sent.
|
|
36
|
+
|
|
37
|
+
``body`` has to be the bytes the request actually carries. Re-encoding a dict
|
|
38
|
+
here would sign something the endpoint never receives.
|
|
39
|
+
"""
|
|
40
|
+
signed = account.sign_message(encode_defunct(text=bound_message(tx_hash, hash_request(body))))
|
|
41
|
+
signature = signed.signature.hex()
|
|
42
|
+
envelope = {
|
|
43
|
+
"txHash": tx_hash,
|
|
44
|
+
"signature": signature if signature.startswith("0x") else "0x" + signature,
|
|
45
|
+
}
|
|
46
|
+
if network:
|
|
47
|
+
envelope["network"] = network
|
|
48
|
+
return base64.b64encode(json.dumps(envelope, separators=(",", ":")).encode("utf-8")).decode("ascii")
|
|
@@ -22,7 +22,7 @@ import requests
|
|
|
22
22
|
|
|
23
23
|
from ._agent import DEFAULT_IMAGE, MAX_COMMAND_BYTES, TRUST_CLASSES, Lease, PrismAgent, PrismError
|
|
24
24
|
|
|
25
|
-
DEFAULT_ESCROW = "
|
|
25
|
+
DEFAULT_ESCROW = "0xfD4228eEEfC49e4b76A0CD40af9fdd546220B2FD"
|
|
26
26
|
PUBLIC_API = "https://api.prismnetwork.tech"
|
|
27
27
|
|
|
28
28
|
NO_WALLET = (
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "prismnetwork"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2"
|
|
8
8
|
description = "Headless GPU leasing on Prism Network for autonomous agents. Wallet-signature auth, on-chain USDG payment, SSH."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
File without changes
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""What a renter's client does about the machine on the other end of a lease."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import socket
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
import unittest
|
|
12
|
+
|
|
13
|
+
from prismnetwork import PrismError, host_key_policy
|
|
14
|
+
from prismnetwork._agent import _host_key_args, _known_hosts_fingerprint, _known_hosts_path
|
|
15
|
+
|
|
16
|
+
SSHD = "/usr/sbin/sshd"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def free_port() -> int:
|
|
20
|
+
with socket.socket() as probe:
|
|
21
|
+
probe.bind(("127.0.0.1", 0))
|
|
22
|
+
return probe.getsockname()[1]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def reachable(port: int) -> bool:
|
|
26
|
+
with socket.socket() as probe:
|
|
27
|
+
probe.settimeout(0.2)
|
|
28
|
+
return probe.connect_ex(("127.0.0.1", port)) == 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def generate_key(directory: str, name: str) -> tuple:
|
|
32
|
+
path = os.path.join(directory, name)
|
|
33
|
+
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", path], check=True)
|
|
34
|
+
listed = subprocess.run(["ssh-keygen", "-lf", path + ".pub"], capture_output=True, text=True, check=True)
|
|
35
|
+
return path, listed.stdout.split()[1]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def serve_sshd(directory: str, host_key: str, port: int):
|
|
39
|
+
"""A real sshd, because what is being tested is whether the key the network
|
|
40
|
+
named is the key the machine actually offers. Nothing logs in: ssh-keyscan
|
|
41
|
+
reads the host key out of the exchange and hangs up."""
|
|
42
|
+
config = os.path.join(directory, "sshd_config")
|
|
43
|
+
with open(config, "w") as handle:
|
|
44
|
+
handle.write(f"Port {port}\nListenAddress 127.0.0.1\nHostKey {host_key}\n"
|
|
45
|
+
"PasswordAuthentication no\nStrictModes no\n")
|
|
46
|
+
child = subprocess.Popen([SSHD, "-D", "-e", "-f", config],
|
|
47
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
48
|
+
for _ in range(50):
|
|
49
|
+
if reachable(port):
|
|
50
|
+
return child
|
|
51
|
+
time.sleep(0.1)
|
|
52
|
+
child.kill()
|
|
53
|
+
raise AssertionError("sshd did not come up")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def stop(child):
|
|
57
|
+
child.kill()
|
|
58
|
+
child.wait()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class HostKeyTest(unittest.TestCase):
|
|
62
|
+
def setUp(self):
|
|
63
|
+
self.dir = tempfile.mkdtemp(prefix="prism-hostkey-")
|
|
64
|
+
self.addCleanup(shutil.rmtree, self.dir, True)
|
|
65
|
+
self.key_path = os.path.join(self.dir, "id_ed25519")
|
|
66
|
+
open(self.key_path, "w").close()
|
|
67
|
+
self.known = _known_hosts_path(self.key_path)
|
|
68
|
+
|
|
69
|
+
@unittest.skipUnless(os.path.exists(SSHD), "no sshd to serve a host key")
|
|
70
|
+
def test_a_published_fingerprint_is_checked_against_the_machine_that_answers(self):
|
|
71
|
+
served, fingerprint = generate_key(self.dir, "served")
|
|
72
|
+
port = free_port()
|
|
73
|
+
sshd = serve_sshd(self.dir, served, port)
|
|
74
|
+
self.addCleanup(stop, sshd)
|
|
75
|
+
|
|
76
|
+
args = _host_key_args("127.0.0.1", port, self.key_path,
|
|
77
|
+
{"channel_key_fingerprint": fingerprint, "channel_key_source": "node_report"},
|
|
78
|
+
False)
|
|
79
|
+
self.assertEqual(args, ["-o", f"UserKnownHostsFile={self.known}", "-o", "StrictHostKeyChecking=yes"])
|
|
80
|
+
with open(self.known) as handle:
|
|
81
|
+
recorded = handle.read().strip().splitlines()
|
|
82
|
+
self.assertEqual(len(recorded), 1, "only the key that was checked is recorded")
|
|
83
|
+
self.assertEqual(recorded[0].split()[0], f"[127.0.0.1]:{port}")
|
|
84
|
+
self.assertEqual(_known_hosts_fingerprint(recorded[0]), fingerprint)
|
|
85
|
+
|
|
86
|
+
# A grant naming a key the machine does not hold has to fail before
|
|
87
|
+
# anything is sent to it.
|
|
88
|
+
os.remove(self.known)
|
|
89
|
+
_, other = generate_key(self.dir, "other")
|
|
90
|
+
with self.assertRaises(PrismError) as caught:
|
|
91
|
+
_host_key_args("127.0.0.1", port, self.key_path, {"channel_key_fingerprint": other}, False)
|
|
92
|
+
self.assertEqual(caught.exception.code, "host_key_mismatch")
|
|
93
|
+
self.assertEqual(caught.exception.body["offered"], [fingerprint])
|
|
94
|
+
self.assertFalse(os.path.exists(self.known), "a machine that failed the check is not recorded")
|
|
95
|
+
|
|
96
|
+
def test_an_unreachable_box_reads_as_a_wait_rather_than_a_wrong_machine(self):
|
|
97
|
+
with self.assertRaises(PrismError) as caught:
|
|
98
|
+
_host_key_args("127.0.0.1", free_port(), self.key_path,
|
|
99
|
+
{"channel_key_fingerprint": "SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU"},
|
|
100
|
+
False)
|
|
101
|
+
self.assertEqual(caught.exception.code, "host_key_unavailable")
|
|
102
|
+
|
|
103
|
+
def test_a_lease_that_publishes_no_key_is_pinned_on_first_sight_or_refused(self):
|
|
104
|
+
args = _host_key_args("127.0.0.1", 2222, self.key_path, {"mode": "direct_ssh"}, False)
|
|
105
|
+
self.assertEqual(args, ["-o", f"UserKnownHostsFile={self.known}",
|
|
106
|
+
"-o", "StrictHostKeyChecking=accept-new"])
|
|
107
|
+
self.assertNotIn("StrictHostKeyChecking=no", args)
|
|
108
|
+
self.assertNotIn("UserKnownHostsFile=/dev/null", args)
|
|
109
|
+
|
|
110
|
+
with self.assertRaises(PrismError) as caught:
|
|
111
|
+
_host_key_args("127.0.0.1", 2222, self.key_path, {"mode": "direct_ssh"}, True)
|
|
112
|
+
self.assertEqual(caught.exception.code, "host_key_unpublished")
|
|
113
|
+
|
|
114
|
+
def test_the_grant_says_which_claim_it_is_making_about_the_host_key(self):
|
|
115
|
+
attested = {"channel_key_fingerprint": "SHA256:a", "channel_key_source": "snp_report"}
|
|
116
|
+
reported = {"channel_key_fingerprint": "SHA256:a", "channel_key_source": "node_report"}
|
|
117
|
+
self.assertEqual(host_key_policy(attested),
|
|
118
|
+
{"mode": "attested", "fingerprint": "SHA256:a", "source": "snp_report"})
|
|
119
|
+
self.assertEqual(host_key_policy(reported),
|
|
120
|
+
{"mode": "reported", "fingerprint": "SHA256:a", "source": "node_report"})
|
|
121
|
+
self.assertEqual(host_key_policy({"mode": "direct_ssh"}),
|
|
122
|
+
{"mode": "unverified", "fingerprint": None, "source": None})
|
|
123
|
+
self.assertEqual(host_key_policy(None),
|
|
124
|
+
{"mode": "unverified", "fingerprint": None, "source": None})
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
unittest.main()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""The payment binding, and whether this SDK signs the same bytes the Node one does."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import unittest
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from eth_account import Account
|
|
14
|
+
from eth_account.messages import encode_defunct
|
|
15
|
+
|
|
16
|
+
from prismnetwork import bound_message, hash_request, payment_header
|
|
17
|
+
|
|
18
|
+
TX = "0x" + "ab" * 32
|
|
19
|
+
KEY = "0x" + "33" * 32
|
|
20
|
+
|
|
21
|
+
# The Node definition these functions are a port of. Absent from an installed
|
|
22
|
+
# wheel, present in a checkout, which is where the comparison is worth making.
|
|
23
|
+
NODE_MODULE = Path(__file__).resolve().parents[2] / "sdk" / "x402.mjs"
|
|
24
|
+
|
|
25
|
+
VECTORS = [
|
|
26
|
+
(TX, "nvidia-smi"),
|
|
27
|
+
(TX.upper().replace("0X", "0x"), "nvidia-smi"),
|
|
28
|
+
("0x" + "0" * 64, ""),
|
|
29
|
+
(TX, '{"model":"llama3.2:3b","prompt":"quelle heure est-il à Paris"}'),
|
|
30
|
+
(TX, "\U0001f680 run it"),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def node_says(vectors):
|
|
35
|
+
"""What ``sdk/x402.mjs`` produces for the same inputs, out of a real node."""
|
|
36
|
+
script = """
|
|
37
|
+
import { boundMessage, hashRequest } from %s;
|
|
38
|
+
const answers = JSON.parse(process.argv[1]).map(([tx, payload]) => [
|
|
39
|
+
hashRequest(payload),
|
|
40
|
+
boundMessage(tx, hashRequest(payload)),
|
|
41
|
+
]);
|
|
42
|
+
console.log(JSON.stringify(answers));
|
|
43
|
+
""" % json.dumps(str(NODE_MODULE))
|
|
44
|
+
out = subprocess.run(
|
|
45
|
+
["node", "--input-type=module", "-e", script, json.dumps(vectors)],
|
|
46
|
+
capture_output=True,
|
|
47
|
+
text=True,
|
|
48
|
+
check=True,
|
|
49
|
+
)
|
|
50
|
+
return json.loads(out.stdout)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BindingTest(unittest.TestCase):
|
|
54
|
+
def test_digest_is_a_plain_sha256_of_the_bytes_on_the_wire(self):
|
|
55
|
+
self.assertEqual(hash_request("nvidia-smi"), hashlib.sha256(b"nvidia-smi").hexdigest())
|
|
56
|
+
self.assertEqual(hash_request(b"nvidia-smi"), hash_request("nvidia-smi"))
|
|
57
|
+
self.assertEqual(hash_request("café"), hashlib.sha256("café".encode("utf-8")).hexdigest())
|
|
58
|
+
|
|
59
|
+
def test_message_names_its_version_the_transaction_and_the_request(self):
|
|
60
|
+
digest = hash_request("nvidia-smi")
|
|
61
|
+
self.assertEqual(bound_message(TX, digest), f"prism-x402:v2\n{TX}\n{digest}")
|
|
62
|
+
# A payer holding a checksummed hash and a server reading a lowercase one
|
|
63
|
+
# have to arrive at the same string.
|
|
64
|
+
self.assertEqual(bound_message(TX.upper().replace("0X", "0x"), digest), bound_message(TX, digest))
|
|
65
|
+
|
|
66
|
+
@unittest.skipUnless(shutil.which("node") and NODE_MODULE.exists(), "needs node and a checkout")
|
|
67
|
+
def test_the_two_sdks_sign_the_same_bytes(self):
|
|
68
|
+
mine = [[hash_request(payload), bound_message(tx, hash_request(payload))] for tx, payload in VECTORS]
|
|
69
|
+
self.assertEqual(mine, node_says(VECTORS))
|
|
70
|
+
|
|
71
|
+
def test_the_header_a_payer_sends_recovers_that_payer(self):
|
|
72
|
+
account = Account.from_key(KEY)
|
|
73
|
+
body = json.dumps({"model": "llama3.2:3b", "prompt": "hello"}).encode("utf-8")
|
|
74
|
+
|
|
75
|
+
envelope = json.loads(base64.b64decode(payment_header(account, TX, body)))
|
|
76
|
+
self.assertEqual(envelope["txHash"], TX)
|
|
77
|
+
self.assertNotIn("network", envelope)
|
|
78
|
+
|
|
79
|
+
signer = Account.recover_message(
|
|
80
|
+
encode_defunct(text=bound_message(TX, hash_request(body))), signature=envelope["signature"]
|
|
81
|
+
)
|
|
82
|
+
self.assertEqual(signer, account.address)
|
|
83
|
+
|
|
84
|
+
# The replay this closes: the same header sent back with a body of the
|
|
85
|
+
# reader's own choosing.
|
|
86
|
+
swapped = Account.recover_message(
|
|
87
|
+
encode_defunct(text=bound_message(TX, hash_request(b"something else"))),
|
|
88
|
+
signature=envelope["signature"],
|
|
89
|
+
)
|
|
90
|
+
self.assertNotEqual(swapped, account.address)
|
|
91
|
+
|
|
92
|
+
def test_a_named_network_travels_with_the_payment(self):
|
|
93
|
+
account = Account.from_key(KEY)
|
|
94
|
+
envelope = json.loads(base64.b64decode(payment_header(account, TX, b"{}", network="eip155:4663")))
|
|
95
|
+
self.assertEqual(envelope["network"], "eip155:4663")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
unittest.main()
|
prismnetwork-0.3.0/README.md
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
# prismnetwork
|
|
2
|
-
|
|
3
|
-
Headless GPU leasing on [Prism Network](https://prismnetwork.tech) for autonomous
|
|
4
|
-
agents, the Python counterpart to `@prismnetwork/agent-sdk`. Give it a wallet; it
|
|
5
|
-
authenticates with a signature, pays on-chain in USDG, provisions a GPU, and runs
|
|
6
|
-
over SSH. No browser, no dashboard.
|
|
7
|
-
|
|
8
|
-
```sh
|
|
9
|
-
pip install prismnetwork
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
```python
|
|
13
|
-
from prismnetwork import PrismAgent, DEFAULT_IMAGE
|
|
14
|
-
|
|
15
|
-
agent = PrismAgent(private_key=AGENT_KEY, escrow="0x62C042265991bEa17B07229322A01850974626dA")
|
|
16
|
-
agent.authenticate()
|
|
17
|
-
|
|
18
|
-
lease = agent.lease(image=DEFAULT_IMAGE, duration_seconds=600, min_vram_mib=16000)
|
|
19
|
-
out = agent.run(lease, "nvidia-smi")
|
|
20
|
-
print(out["stdout"])
|
|
21
|
-
agent.end_lease(lease)
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
## What it does
|
|
25
|
-
|
|
26
|
-
`authenticate()` signs a challenge with the wallet and exchanges it for a bearer
|
|
27
|
-
session. `lease()` gets a quote, funds an on-chain USDG escrow bound to the quote
|
|
28
|
-
(`createLease`), waits for the GPU to provision, and returns SSH access. `run()`
|
|
29
|
-
executes a command over SSH, retrying through the host's sshd warmup. Metering is
|
|
30
|
-
per-second; each lease settles on-chain with a verifiable receipt.
|
|
31
|
-
|
|
32
|
-
Read-only helpers: `offers()`, `balances()`, `leases()`, `quote(...)`, `access(id)`.
|
|
33
|
-
|
|
34
|
-
## Batch
|
|
35
|
-
|
|
36
|
-
Pass `command=` to `lease()` and the node runs that one command and reports its
|
|
37
|
-
output instead of granting SSH access:
|
|
38
|
-
|
|
39
|
-
```python
|
|
40
|
-
batch = agent.lease(image=DEFAULT_IMAGE, duration_seconds=600, command="nvidia-smi")
|
|
41
|
-
print(batch.result["stdout"])
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
Batch leases match only suppliers at trust class `isolated` or above, because the
|
|
45
|
-
broker path has no signed result channel. Commands are capped at 8 KiB and output
|
|
46
|
-
at 64 KiB per stream. `result(lease_id)` and `wait_for_result(lease_id)` read the
|
|
47
|
-
output of an already-funded batch lease.
|
|
48
|
-
|
|
49
|
-
The wallet needs USDG (`0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168`, 6 decimals) and
|
|
50
|
-
native Robinhood-Chain gas.
|
|
51
|
-
|
|
52
|
-
Requires `ssh` and `ssh-keygen` on `PATH`. Chain id 4663, RPC
|
|
53
|
-
`https://rpc.mainnet.chain.robinhood.com`.
|
|
54
|
-
|
|
55
|
-
Prism is pre-production and unaudited. A permissionless supplier is not a trusted
|
|
56
|
-
computing environment; do not lease with a wallet or workload you cannot lose.
|
|
File without changes
|