foliant-protocol 0.1.0__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.
- foliant/__init__.py +18 -0
- foliant/accounts.py +232 -0
- foliant/agent.py +139 -0
- foliant/channels.py +149 -0
- foliant/crypto.py +111 -0
- foliant/errors.py +26 -0
- foliant/ledger.py +363 -0
- foliant/market.py +88 -0
- foliant/pools.py +123 -0
- foliant/x402.py +225 -0
- foliant_protocol-0.1.0.dist-info/METADATA +120 -0
- foliant_protocol-0.1.0.dist-info/RECORD +16 -0
- foliant_protocol-0.1.0.dist-info/WHEEL +5 -0
- foliant_protocol-0.1.0.dist-info/licenses/LICENSE +202 -0
- foliant_protocol-0.1.0.dist-info/licenses/NOTICE +14 -0
- foliant_protocol-0.1.0.dist-info/top_level.txt +1 -0
foliant/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Foliant reference implementation (Python) — agent accounts, policies, channels, pools.
|
|
2
|
+
|
|
3
|
+
Executable specification for whitepaper §6; the Cosmos SDK port follows these
|
|
4
|
+
state machines one-to-one and reuses the tests in `tests/` as a conformance suite.
|
|
5
|
+
"""
|
|
6
|
+
from .accounts import AgentAccount, AgentSigner, Attestation, Policy
|
|
7
|
+
from .agent import Agent
|
|
8
|
+
from .channels import Channel
|
|
9
|
+
from .crypto import KeyPair, PublicKey, Signed, sign
|
|
10
|
+
from .ledger import Ledger
|
|
11
|
+
from .market import Contribution, Receipt, ServiceOffer, royalty_split
|
|
12
|
+
from .pools import Pool
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Agent", "AgentAccount", "AgentSigner", "Attestation", "Channel", "Contribution",
|
|
16
|
+
"KeyPair", "Ledger", "Policy", "Pool", "PublicKey", "Receipt", "ServiceOffer",
|
|
17
|
+
"Signed", "royalty_split", "sign",
|
|
18
|
+
]
|
foliant/accounts.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Agent accounts, spending policies and attestation (whitepaper §6.1-6.3).
|
|
2
|
+
|
|
3
|
+
An AgentAccount is operated by a `signer` key under a `Policy`. The policy is
|
|
4
|
+
checked by the ledger (on-chain) for every value-moving transaction, and by
|
|
5
|
+
the AgentSigner (the enclave side) for every off-chain channel or pool update.
|
|
6
|
+
Both sides run the same `Policy.check`, so a signer that refuses to sign and a
|
|
7
|
+
ledger that refuses to apply agree exactly.
|
|
8
|
+
|
|
9
|
+
Accounts form a tree (whitepaper §6.2, hierarchical budgets). An account's
|
|
10
|
+
signer may `delegate`: create a child account with its own signer and a policy
|
|
11
|
+
that sits within the parent's (`Policy.within`). Value leaving the tree is
|
|
12
|
+
checked against, and recorded in, every ancestor's policy and window, so no
|
|
13
|
+
branch can ever commit more than any ancestor allows, whatever its own policy
|
|
14
|
+
says. Moves inside the tree (delegating funds down, recalling them up) are not
|
|
15
|
+
spends.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from .crypto import KeyPair, PublicKey, Signed, hash_obj, sign
|
|
23
|
+
from .errors import PolicyViolation
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Policy:
|
|
28
|
+
per_tx_max: int
|
|
29
|
+
per_window_max: int
|
|
30
|
+
window_secs: int
|
|
31
|
+
allow_list: Optional[frozenset[str]] = None # payee addresses; None = any
|
|
32
|
+
deny_list: frozenset[str] = frozenset()
|
|
33
|
+
expiry: Optional[int] = None # unix seconds; None = never
|
|
34
|
+
escalation: Optional[PublicKey] = None # co-signer that may exceed per_tx_max
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def id(self) -> str:
|
|
38
|
+
return hash_obj(self.to_dict())
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict:
|
|
41
|
+
return {
|
|
42
|
+
"per_tx_max": self.per_tx_max,
|
|
43
|
+
"per_window_max": self.per_window_max,
|
|
44
|
+
"window_secs": self.window_secs,
|
|
45
|
+
"allow_list": sorted(self.allow_list) if self.allow_list is not None else None,
|
|
46
|
+
"deny_list": sorted(self.deny_list),
|
|
47
|
+
"expiry": self.expiry,
|
|
48
|
+
"escalation": self.escalation.to_dict() if self.escalation else None,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, d: dict) -> "Policy":
|
|
53
|
+
return cls(
|
|
54
|
+
per_tx_max=d["per_tx_max"],
|
|
55
|
+
per_window_max=d["per_window_max"],
|
|
56
|
+
window_secs=d["window_secs"],
|
|
57
|
+
allow_list=frozenset(d["allow_list"]) if d.get("allow_list") is not None else None,
|
|
58
|
+
deny_list=frozenset(d.get("deny_list", [])),
|
|
59
|
+
expiry=d.get("expiry"),
|
|
60
|
+
escalation=PublicKey.from_dict(d["escalation"]) if d.get("escalation") else None,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def check(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
amount: int,
|
|
67
|
+
payee: str,
|
|
68
|
+
now: int,
|
|
69
|
+
spent_in_window: int,
|
|
70
|
+
escalated: bool = False,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Raise PolicyViolation if a payment of `amount` to `payee` is not allowed."""
|
|
73
|
+
if amount < 0:
|
|
74
|
+
raise PolicyViolation("negative amount")
|
|
75
|
+
if self.expiry is not None and now >= self.expiry:
|
|
76
|
+
raise PolicyViolation("policy expired")
|
|
77
|
+
if payee in self.deny_list:
|
|
78
|
+
raise PolicyViolation(f"payee {payee} is denied")
|
|
79
|
+
if self.allow_list is not None and payee not in self.allow_list:
|
|
80
|
+
raise PolicyViolation(f"payee {payee} is not on the allow list")
|
|
81
|
+
if amount > self.per_tx_max and not escalated:
|
|
82
|
+
raise PolicyViolation(f"amount {amount} exceeds per_tx_max {self.per_tx_max}")
|
|
83
|
+
if spent_in_window + amount > self.per_window_max:
|
|
84
|
+
raise PolicyViolation(
|
|
85
|
+
f"amount {amount} would exceed per_window_max {self.per_window_max} "
|
|
86
|
+
f"(already spent {spent_in_window})"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def within(self, parent: "Policy") -> None:
|
|
90
|
+
"""Raise PolicyViolation unless this policy is no wider than `parent` on every axis.
|
|
91
|
+
|
|
92
|
+
Runtime enforcement up the tree makes this a guarantee at delegation time
|
|
93
|
+
rather than the only line of defence: a child that somehow held a wider
|
|
94
|
+
policy would still be bounded by its ancestors at every spend.
|
|
95
|
+
|
|
96
|
+
Not compared, deliberately: `window_secs` (a child may meter over a shorter
|
|
97
|
+
window than its parent; the parent's window still binds the subtree) and
|
|
98
|
+
`escalation` (a child's co-signer lifts only the child's own per_tx_max;
|
|
99
|
+
see Ledger._authorise).
|
|
100
|
+
"""
|
|
101
|
+
if self.per_tx_max > parent.per_tx_max:
|
|
102
|
+
raise PolicyViolation(f"child per_tx_max {self.per_tx_max} exceeds parent {parent.per_tx_max}")
|
|
103
|
+
if self.per_window_max > parent.per_window_max:
|
|
104
|
+
raise PolicyViolation(f"child per_window_max {self.per_window_max} exceeds parent {parent.per_window_max}")
|
|
105
|
+
if parent.allow_list is not None and (self.allow_list is None or not self.allow_list <= parent.allow_list):
|
|
106
|
+
raise PolicyViolation("child allow list must be a subset of the parent's")
|
|
107
|
+
if not parent.deny_list <= self.deny_list:
|
|
108
|
+
raise PolicyViolation("child deny list must include the parent's")
|
|
109
|
+
if parent.expiry is not None and (self.expiry is None or self.expiry > parent.expiry):
|
|
110
|
+
raise PolicyViolation("child expiry must not be later than the parent's")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class Attestation:
|
|
115
|
+
"""A remote-attestation quote binding `signer` to `code_hash`, signed by a vendor key.
|
|
116
|
+
|
|
117
|
+
Simulated: the vendor is any key the ledger has registered as a trusted
|
|
118
|
+
attestation root. Real quotes (TDX, SEV-SNP, Nitro) are verified by a
|
|
119
|
+
precompile; the shape is the same.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
code_hash: str
|
|
123
|
+
signer: PublicKey
|
|
124
|
+
quote: Signed
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def issue(vendor: KeyPair, code_hash: str, signer: PublicKey) -> "Attestation":
|
|
128
|
+
body = {"code_hash": code_hash, "signer": signer.to_dict()}
|
|
129
|
+
return Attestation(code_hash, signer, sign(vendor, body))
|
|
130
|
+
|
|
131
|
+
def valid_for(self, trusted_vendors: set[str]) -> bool:
|
|
132
|
+
return (
|
|
133
|
+
self.quote.valid()
|
|
134
|
+
and self.quote.signer.hex in trusted_vendors
|
|
135
|
+
and self.quote.body == {"code_hash": self.code_hash, "signer": self.signer.to_dict()}
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class SpendWindow:
|
|
141
|
+
"""Rolling record of value the account has committed, for per_window_max."""
|
|
142
|
+
|
|
143
|
+
entries: list[tuple[int, int]] = field(default_factory=list) # (ts, amount)
|
|
144
|
+
|
|
145
|
+
def spent(self, now: int, window_secs: int) -> int:
|
|
146
|
+
cutoff = now - window_secs
|
|
147
|
+
self.entries = [(t, a) for t, a in self.entries if t > cutoff]
|
|
148
|
+
return sum(a for _, a in self.entries)
|
|
149
|
+
|
|
150
|
+
def record(self, now: int, amount: int) -> None:
|
|
151
|
+
self.entries.append((now, amount))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class AgentAccount:
|
|
156
|
+
id: str
|
|
157
|
+
owner: PublicKey
|
|
158
|
+
signer: PublicKey
|
|
159
|
+
policy: Policy
|
|
160
|
+
attestation: Optional[Attestation] = None
|
|
161
|
+
nonce: int = 0
|
|
162
|
+
window: SpendWindow = field(default_factory=SpendWindow)
|
|
163
|
+
parent: Optional[str] = None # account id this one was delegated from; None = root
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def make_id(owner: PublicKey, signer: PublicKey, nonce_salt: int) -> str:
|
|
167
|
+
return hash_obj({"owner": owner.to_dict(), "signer": signer.to_dict(), "salt": nonce_salt})
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def make_child_id(parent_id: str, signer: PublicKey, nonce_salt: int) -> str:
|
|
171
|
+
return hash_obj({"parent": parent_id, "signer": signer.to_dict(), "salt": nonce_salt})
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def address(self) -> str:
|
|
175
|
+
"""Where the account's funds sit on the ledger."""
|
|
176
|
+
return f"account:{self.id}"
|
|
177
|
+
|
|
178
|
+
def authorise(self, *, amount: int, payee: str, now: int, escalated: bool = False) -> None:
|
|
179
|
+
"""Policy check then record the spend. Raises PolicyViolation without recording."""
|
|
180
|
+
spent = self.window.spent(now, self.policy.window_secs)
|
|
181
|
+
self.policy.check(
|
|
182
|
+
amount=amount, payee=payee, now=now, spent_in_window=spent, escalated=escalated
|
|
183
|
+
)
|
|
184
|
+
self.window.record(now, amount)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class AgentSigner:
|
|
188
|
+
"""The enclave side: holds the signer key and refuses to sign anything the policy forbids.
|
|
189
|
+
|
|
190
|
+
It keeps its own SpendWindow so per_window_max holds across off-chain updates
|
|
191
|
+
that the ledger never sees individually.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
def __init__(self, keypair: KeyPair, policy: Policy, account_id: str):
|
|
195
|
+
self.keypair = keypair
|
|
196
|
+
self.policy = policy
|
|
197
|
+
self.account_id = account_id
|
|
198
|
+
self.window = SpendWindow()
|
|
199
|
+
self._last_balance: dict[str, int] = {} # channel/pool id -> last signed balance
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def public(self) -> PublicKey:
|
|
203
|
+
return self.keypair.public
|
|
204
|
+
|
|
205
|
+
def sign_payment(self, *, payee: str, amount: int, now: int, body: dict, escalated: bool = False) -> Signed:
|
|
206
|
+
"""Sign `body` if committing `amount` to `payee` is within policy."""
|
|
207
|
+
spent = self.window.spent(now, self.policy.window_secs)
|
|
208
|
+
self.policy.check(amount=amount, payee=payee, now=now, spent_in_window=spent, escalated=escalated)
|
|
209
|
+
self.window.record(now, amount)
|
|
210
|
+
return sign(self.keypair, body)
|
|
211
|
+
|
|
212
|
+
def sign_update(self, *, kind: str, obj_id: str, payee: str, seq: int, balance: int, now: int) -> Signed:
|
|
213
|
+
"""Sign a channel or pool update.
|
|
214
|
+
|
|
215
|
+
The policy bounds *committed* value: the deposit was authorised when the
|
|
216
|
+
channel or pool was funded, and every update is bounded by that deposit,
|
|
217
|
+
so an update is not a new spend. The signer still enforces the payee
|
|
218
|
+
allow/deny lists, expiry, and monotonic balances.
|
|
219
|
+
"""
|
|
220
|
+
last = self._last_balance.get(obj_id, 0)
|
|
221
|
+
if balance < last:
|
|
222
|
+
raise PolicyViolation("balance must not decrease")
|
|
223
|
+
spent = self.window.spent(now, self.policy.window_secs)
|
|
224
|
+
self.policy.check(amount=0, payee=payee, now=now, spent_in_window=spent)
|
|
225
|
+
body = {"kind": kind, "id": obj_id, "seq": seq, "balance": balance, "account": self.account_id}
|
|
226
|
+
signed = sign(self.keypair, body)
|
|
227
|
+
self._last_balance[obj_id] = balance
|
|
228
|
+
return signed
|
|
229
|
+
|
|
230
|
+
def sign_plain(self, body: dict) -> Signed:
|
|
231
|
+
"""Sign a non-payment message (account maintenance, exits, receipts)."""
|
|
232
|
+
return sign(self.keypair, body)
|
foliant/agent.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Agent-side convenience: builds envelopes and off-chain updates for one account.
|
|
2
|
+
|
|
3
|
+
This is what an agent SDK wraps. The signer runs the same policy as the ledger,
|
|
4
|
+
so a payment the enclave refuses to sign is one the chain would have rejected.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from .accounts import AgentAccount, AgentSigner, Attestation, Policy
|
|
11
|
+
from .crypto import KeyPair, Signed, sign
|
|
12
|
+
from .ledger import Ledger
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Agent:
|
|
16
|
+
def __init__(self, ledger: Ledger, owner: KeyPair, signer_kp: KeyPair, policy: Policy,
|
|
17
|
+
attestation: Optional[Attestation] = None, salt: int = 0):
|
|
18
|
+
self.ledger = ledger
|
|
19
|
+
self.owner = owner
|
|
20
|
+
reg = sign(owner, {"op": "register", "signer": signer_kp.public.to_dict(), "policy_id": policy.id, "salt": salt})
|
|
21
|
+
self.account = ledger.register_account(reg, signer=signer_kp.public, policy=policy, attestation=attestation, salt=salt)
|
|
22
|
+
self.signer = AgentSigner(signer_kp, policy, self.account.id)
|
|
23
|
+
self._channel_seq: dict[str, int] = {}
|
|
24
|
+
self._pool_seq: dict[str, int] = {}
|
|
25
|
+
self.latest: dict[str, Signed] = {} # obj id -> last update we signed
|
|
26
|
+
|
|
27
|
+
# --- envelopes (on-chain) --------------------------------------------
|
|
28
|
+
|
|
29
|
+
def _env(self, op: str, **params) -> Signed:
|
|
30
|
+
body = {"account": self.account.id, "nonce": self.account.nonce, "op": op, **params}
|
|
31
|
+
return self.signer.sign_plain(body)
|
|
32
|
+
|
|
33
|
+
def submit(self, op: str, escalate_with: Optional[KeyPair] = None, *,
|
|
34
|
+
spend: int = 0, payee_addr: str = "", **params) -> dict:
|
|
35
|
+
"""Sign and apply an envelope. Value-moving ops pass `spend`/`payee_addr` so the
|
|
36
|
+
signer runs the same policy check the ledger will run."""
|
|
37
|
+
body = {"account": self.account.id, "nonce": self.account.nonce, "op": op, **params}
|
|
38
|
+
snapshot = list(self.signer.window.entries)
|
|
39
|
+
if spend or payee_addr:
|
|
40
|
+
env = self.signer.sign_payment(payee=payee_addr, amount=spend, now=self.ledger.now, body=body,
|
|
41
|
+
escalated=escalate_with is not None)
|
|
42
|
+
else:
|
|
43
|
+
env = self.signer.sign_plain(body)
|
|
44
|
+
esc = sign(escalate_with, env.body) if escalate_with else None
|
|
45
|
+
try:
|
|
46
|
+
return self.ledger.apply(env, esc)
|
|
47
|
+
except Exception:
|
|
48
|
+
# the ledger refused (an ancestor's policy, funds, a duplicate): the signer's
|
|
49
|
+
# window must not keep a spend that never happened, or it diverges from the ledger
|
|
50
|
+
self.signer.window.entries = snapshot
|
|
51
|
+
raise
|
|
52
|
+
|
|
53
|
+
# --- the tree -----------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def delegate(self, signer_kp: KeyPair, policy: Policy, *, fund: int = 0, asset: str = "", salt: int = 0) -> "Agent":
|
|
56
|
+
"""Create a child account operated by `signer_kp` under `policy` (which must sit
|
|
57
|
+
within this account's), fund it, and return an Agent for it."""
|
|
58
|
+
res = self.submit("delegate", signer=signer_kp.public.to_dict(), policy=policy.to_dict(),
|
|
59
|
+
fund=fund, asset=asset, salt=salt)
|
|
60
|
+
child = self.ledger.accounts[res["account_id"]]
|
|
61
|
+
return Agent.attach(self.ledger, child, signer_kp, owner=self.owner)
|
|
62
|
+
|
|
63
|
+
def recall(self, child: "Agent", asset: str, amount: Optional[int] = None) -> dict:
|
|
64
|
+
return self.submit("recall", child=child.account.id, asset=asset, amount=amount)
|
|
65
|
+
|
|
66
|
+
def set_child_policy(self, child: "Agent", policy: Policy) -> dict:
|
|
67
|
+
"""An ancestor's signer sets a descendant's policy; the child's signer must be told."""
|
|
68
|
+
body = {"account": child.account.id, "nonce": child.account.nonce, "op": "set_policy", "policy": policy.to_dict()}
|
|
69
|
+
res = self.ledger.apply(self.signer.sign_plain(body))
|
|
70
|
+
child.signer.policy = policy
|
|
71
|
+
return res
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def attach(cls, ledger: Ledger, account: AgentAccount, signer_kp: KeyPair, *, owner: KeyPair) -> "Agent":
|
|
75
|
+
"""An Agent object for an account that already exists on the ledger."""
|
|
76
|
+
self = cls.__new__(cls)
|
|
77
|
+
self.ledger, self.owner, self.account = ledger, owner, account
|
|
78
|
+
self.signer = AgentSigner(signer_kp, account.policy, account.id)
|
|
79
|
+
self._channel_seq, self._pool_seq, self.latest = {}, {}, {}
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def transfer(self, to: str, asset: str, amount: int, **kw) -> dict:
|
|
83
|
+
return self.submit("transfer", spend=amount, payee_addr=to, to=to, asset=asset, amount=amount, **kw)
|
|
84
|
+
|
|
85
|
+
def open_channel(self, payee: str, asset: str, deposit: int, timeout_secs: int = 3600, salt: int = 0, **kw) -> str:
|
|
86
|
+
return self.submit("open_channel", spend=deposit, payee_addr=payee, payee=payee, asset=asset, deposit=deposit,
|
|
87
|
+
timeout_secs=timeout_secs, salt=salt, **kw)["channel_id"]
|
|
88
|
+
|
|
89
|
+
def close_channel(self, channel_id: str) -> dict:
|
|
90
|
+
latest = self.latest.get(channel_id)
|
|
91
|
+
return self.submit("close_channel", channel_id=channel_id, latest=latest.to_dict() if latest else None)
|
|
92
|
+
|
|
93
|
+
def finalize_close(self, channel_id: str) -> dict:
|
|
94
|
+
return self.submit("finalize_close", channel_id=channel_id)
|
|
95
|
+
|
|
96
|
+
def join_pool(self, pool_id: str, deposit: int, **kw) -> dict:
|
|
97
|
+
coordinator = self.ledger.pools[pool_id].coordinator
|
|
98
|
+
return self.submit("join_pool", spend=deposit, payee_addr=coordinator, pool_id=pool_id, deposit=deposit, **kw)
|
|
99
|
+
|
|
100
|
+
def begin_exit(self, pool_id: str) -> dict:
|
|
101
|
+
latest = self.latest.get(pool_id)
|
|
102
|
+
return self.submit("begin_exit", pool_id=pool_id, latest=latest.to_dict() if latest else None)
|
|
103
|
+
|
|
104
|
+
def finalize_exit(self, pool_id: str) -> dict:
|
|
105
|
+
return self.submit("finalize_exit", pool_id=pool_id)
|
|
106
|
+
|
|
107
|
+
# --- off-chain updates -----------------------------------------------
|
|
108
|
+
|
|
109
|
+
def pay_channel(self, channel_id: str, amount: int) -> Signed:
|
|
110
|
+
"""Sign the next channel update adding `amount` for the payee."""
|
|
111
|
+
ch = self.ledger.channels[channel_id]
|
|
112
|
+
seq = self._channel_seq.get(channel_id, ch.seq) + 1
|
|
113
|
+
balance = (self.latest[channel_id].body["balance"] if channel_id in self.latest else ch.balance_to_payee) + amount
|
|
114
|
+
if balance > ch.deposit:
|
|
115
|
+
raise ValueError("channel deposit exhausted")
|
|
116
|
+
u = self.signer.sign_update(kind="channel", obj_id=channel_id, payee=ch.payee, seq=seq, balance=balance, now=self.ledger.now)
|
|
117
|
+
self._channel_seq[channel_id] = seq
|
|
118
|
+
self.latest[channel_id] = u
|
|
119
|
+
return u
|
|
120
|
+
|
|
121
|
+
def pay_pool(self, pool_id: str, amount: int) -> Signed:
|
|
122
|
+
pool = self.ledger.pools[pool_id]
|
|
123
|
+
claim = pool.members[self.account.id]
|
|
124
|
+
seq = self._pool_seq.get(pool_id, claim.seq) + 1
|
|
125
|
+
balance = (self.latest[pool_id].body["balance"] if pool_id in self.latest else claim.paid) + amount
|
|
126
|
+
if balance > claim.deposit:
|
|
127
|
+
raise ValueError("pool deposit exhausted")
|
|
128
|
+
u = self.signer.sign_update(kind="pool", obj_id=pool_id, payee=pool.coordinator, seq=seq, balance=balance, now=self.ledger.now)
|
|
129
|
+
self._pool_seq[pool_id] = seq
|
|
130
|
+
self.latest[pool_id] = u
|
|
131
|
+
return u
|
|
132
|
+
|
|
133
|
+
def start_stream(self, channel_id: str, rate_per_sec: int) -> Signed:
|
|
134
|
+
ch = self.ledger.channels[channel_id]
|
|
135
|
+
# the deposit was authorised at open; a stream only schedules it
|
|
136
|
+
body = {"kind": "stream", "id": channel_id, "account": self.account.id, "rate": rate_per_sec, "start": self.ledger.now}
|
|
137
|
+
s = self.signer.sign_payment(payee=ch.payee, amount=0, now=self.ledger.now, body=body)
|
|
138
|
+
self.ledger.start_stream(channel_id, s)
|
|
139
|
+
return s
|
foliant/channels.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Payment channels and streams (whitepaper §6.4, §6.6).
|
|
2
|
+
|
|
3
|
+
A channel escrows `deposit` from a payer account for one payee. Off-chain the
|
|
4
|
+
payer signs updates (channel_id, seq, balance) with balance monotonically
|
|
5
|
+
increasing. The payee settles by submitting the latest update at any time;
|
|
6
|
+
the payer closes by submitting its latest update and waiting `timeout_secs`
|
|
7
|
+
for the payee to submit a higher one.
|
|
8
|
+
|
|
9
|
+
State machines here are pure: methods validate and return Transfer effects;
|
|
10
|
+
the Ledger executes them.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from .crypto import PublicKey, Signed, hash_obj
|
|
18
|
+
from .errors import InvalidSignatureError, InvalidUpdate, Unauthorized
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Transfer:
|
|
23
|
+
src: str # address, or "escrow:<obj id>"
|
|
24
|
+
dst: str
|
|
25
|
+
asset: str
|
|
26
|
+
amount: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def verify_update(signed: Signed, *, kind: str, obj_id: str, account_id: str, signer: PublicKey) -> tuple[int, int]:
|
|
30
|
+
"""Check an update's signature and shape; return (seq, balance)."""
|
|
31
|
+
if not signed.valid():
|
|
32
|
+
raise InvalidSignatureError("bad signature on update")
|
|
33
|
+
if signed.signer != signer:
|
|
34
|
+
raise Unauthorized("update not signed by the account's current signer")
|
|
35
|
+
b = signed.body
|
|
36
|
+
if b.get("kind") != kind or b.get("id") != obj_id or b.get("account") != account_id:
|
|
37
|
+
raise InvalidUpdate("update is for a different object or account")
|
|
38
|
+
seq, balance = b["seq"], b["balance"]
|
|
39
|
+
if not (isinstance(seq, int) and isinstance(balance, int)) or seq < 0 or balance < 0:
|
|
40
|
+
raise InvalidUpdate("seq and balance must be non-negative integers")
|
|
41
|
+
return seq, balance
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Channel:
|
|
46
|
+
id: str
|
|
47
|
+
payer_account: str
|
|
48
|
+
payee: str # address
|
|
49
|
+
asset: str
|
|
50
|
+
deposit: int
|
|
51
|
+
timeout_secs: int
|
|
52
|
+
balance_to_payee: int = 0 # settled so far
|
|
53
|
+
seq: int = 0 # seq of the last update applied on-chain
|
|
54
|
+
closing_at: Optional[int] = None
|
|
55
|
+
closed: bool = False
|
|
56
|
+
# streaming: a signed (rate, start) lets the payee claim rate*(now-start)
|
|
57
|
+
stream: Optional[dict] = field(default=None)
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def make_id(payer_account: str, payee: str, salt: int) -> str:
|
|
61
|
+
return hash_obj({"payer": payer_account, "payee": payee, "salt": salt})
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def escrow(self) -> str:
|
|
65
|
+
return f"escrow:{self.id}"
|
|
66
|
+
|
|
67
|
+
def _apply_balance(self, seq: int, balance: int) -> list[Transfer]:
|
|
68
|
+
if self.closed:
|
|
69
|
+
raise InvalidUpdate("channel is closed")
|
|
70
|
+
if seq <= self.seq:
|
|
71
|
+
raise InvalidUpdate(f"seq {seq} not greater than last applied {self.seq}")
|
|
72
|
+
if balance < self.balance_to_payee:
|
|
73
|
+
raise InvalidUpdate("balance may not decrease")
|
|
74
|
+
if balance > self.deposit:
|
|
75
|
+
raise InvalidUpdate(f"balance {balance} exceeds deposit {self.deposit}")
|
|
76
|
+
delta = balance - self.balance_to_payee
|
|
77
|
+
self.seq, self.balance_to_payee = seq, balance
|
|
78
|
+
return [Transfer(self.escrow, self.payee, self.asset, delta)] if delta else []
|
|
79
|
+
|
|
80
|
+
def settle(self, update: Signed, *, account_id: str, signer: PublicKey) -> list[Transfer]:
|
|
81
|
+
"""Payee (or anyone) submits a payer-signed update; pays out the delta."""
|
|
82
|
+
seq, balance = verify_update(update, kind="channel", obj_id=self.id, account_id=account_id, signer=signer)
|
|
83
|
+
return self._apply_balance(seq, balance)
|
|
84
|
+
|
|
85
|
+
def begin_close(self, now: int, latest: Optional[Signed], *, account_id: str, signer: PublicKey) -> list[Transfer]:
|
|
86
|
+
"""Payer starts closing with its latest update (or none). Payee has `timeout_secs` to top it."""
|
|
87
|
+
if self.closed:
|
|
88
|
+
raise InvalidUpdate("channel is closed")
|
|
89
|
+
effects: list[Transfer] = []
|
|
90
|
+
if latest is not None:
|
|
91
|
+
seq, balance = verify_update(latest, kind="channel", obj_id=self.id, account_id=account_id, signer=signer)
|
|
92
|
+
if seq > self.seq:
|
|
93
|
+
effects = self._apply_balance(seq, balance)
|
|
94
|
+
if self.closing_at is None:
|
|
95
|
+
self.closing_at = now + self.timeout_secs
|
|
96
|
+
return effects
|
|
97
|
+
|
|
98
|
+
def finalize_close(self, now: int) -> list[Transfer]:
|
|
99
|
+
"""After the timeout, return the unspent deposit to the payer."""
|
|
100
|
+
if self.closed:
|
|
101
|
+
raise InvalidUpdate("channel is closed")
|
|
102
|
+
if self.closing_at is None or now < self.closing_at:
|
|
103
|
+
raise InvalidUpdate("close timeout has not elapsed")
|
|
104
|
+
effects = self.claim_stream(now)
|
|
105
|
+
remaining = self.deposit - self.balance_to_payee
|
|
106
|
+
self.closed = True
|
|
107
|
+
if remaining:
|
|
108
|
+
effects.append(Transfer(self.escrow, f"account:{self.payer_account}", self.asset, remaining))
|
|
109
|
+
return effects
|
|
110
|
+
|
|
111
|
+
# --- streaming -------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def start_stream(self, signed: Signed, *, account_id: str, signer: PublicKey) -> None:
|
|
114
|
+
"""Payer signs (rate_per_sec, start_ts); claimable grows with time until stopped."""
|
|
115
|
+
if not signed.valid() or signed.signer != signer:
|
|
116
|
+
raise InvalidSignatureError("bad stream signature")
|
|
117
|
+
b = signed.body
|
|
118
|
+
if b.get("kind") != "stream" or b.get("id") != self.id or b.get("account") != account_id:
|
|
119
|
+
raise InvalidUpdate("stream is for a different channel")
|
|
120
|
+
if self.stream is not None:
|
|
121
|
+
raise InvalidUpdate("stream already running")
|
|
122
|
+
self.stream = {"rate": int(b["rate"]), "start": int(b["start"]), "stopped": None}
|
|
123
|
+
|
|
124
|
+
def stop_stream(self, now: int) -> None:
|
|
125
|
+
if self.stream and self.stream["stopped"] is None:
|
|
126
|
+
self.stream["stopped"] = now
|
|
127
|
+
|
|
128
|
+
def stream_claimable(self, now: int) -> int:
|
|
129
|
+
if not self.stream:
|
|
130
|
+
return 0
|
|
131
|
+
end = self.stream["stopped"] if self.stream["stopped"] is not None else now
|
|
132
|
+
elapsed = max(0, end - self.stream["start"])
|
|
133
|
+
return min(self.deposit, self.balance_to_payee + self.stream["rate"] * elapsed)
|
|
134
|
+
|
|
135
|
+
def claim_stream(self, now: int) -> list[Transfer]:
|
|
136
|
+
"""Pay out whatever the stream has accrued; folds it into balance_to_payee."""
|
|
137
|
+
if not self.stream:
|
|
138
|
+
return []
|
|
139
|
+
target = self.stream_claimable(now)
|
|
140
|
+
delta = target - self.balance_to_payee
|
|
141
|
+
if delta <= 0:
|
|
142
|
+
return []
|
|
143
|
+
# streams settle by time, not seq; keep seq unchanged
|
|
144
|
+
self.balance_to_payee = target
|
|
145
|
+
if self.stream["stopped"] is not None:
|
|
146
|
+
self.stream = None
|
|
147
|
+
else:
|
|
148
|
+
self.stream["start"] = now # restart accrual from the claim point
|
|
149
|
+
return [Transfer(self.escrow, self.payee, self.asset, delta)]
|
foliant/crypto.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Keys, signatures and canonical hashing.
|
|
2
|
+
|
|
3
|
+
Ed25519 stands in for the production scheme. Whitepaper principle 9 requires
|
|
4
|
+
signature agility, so every signed message carries a `scheme` tag and the
|
|
5
|
+
verifier dispatches on it; only "ed25519" is implemented here.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from cryptography.hazmat.primitives import serialization
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
16
|
+
Ed25519PrivateKey,
|
|
17
|
+
Ed25519PublicKey,
|
|
18
|
+
)
|
|
19
|
+
from cryptography.exceptions import InvalidSignature
|
|
20
|
+
|
|
21
|
+
SCHEME = "ed25519"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def canonical(obj: Any) -> bytes:
|
|
25
|
+
"""Deterministic JSON encoding used for every signed or hashed message."""
|
|
26
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sha256(data: bytes) -> str:
|
|
30
|
+
return hashlib.sha256(data).hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def hash_obj(obj: Any) -> str:
|
|
34
|
+
return sha256(canonical(obj))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class PublicKey:
|
|
39
|
+
scheme: str
|
|
40
|
+
raw: bytes
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def hex(self) -> str:
|
|
44
|
+
return self.raw.hex()
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def address(self) -> str:
|
|
48
|
+
"""Address = first 20 bytes of sha256(scheme || pubkey)."""
|
|
49
|
+
return sha256(self.scheme.encode() + self.raw)[:40]
|
|
50
|
+
|
|
51
|
+
def verify(self, message: bytes, signature: bytes) -> bool:
|
|
52
|
+
if self.scheme != SCHEME:
|
|
53
|
+
raise ValueError(f"unsupported scheme {self.scheme}")
|
|
54
|
+
try:
|
|
55
|
+
Ed25519PublicKey.from_public_bytes(self.raw).verify(signature, message)
|
|
56
|
+
return True
|
|
57
|
+
except InvalidSignature:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict:
|
|
61
|
+
return {"scheme": self.scheme, "key": self.hex}
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_dict(cls, d: dict) -> "PublicKey":
|
|
65
|
+
return cls(d["scheme"], bytes.fromhex(d["key"]))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class KeyPair:
|
|
69
|
+
def __init__(self, private: Ed25519PrivateKey | None = None):
|
|
70
|
+
self._priv = private or Ed25519PrivateKey.generate()
|
|
71
|
+
raw = self._priv.public_key().public_bytes(
|
|
72
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
73
|
+
)
|
|
74
|
+
self.public = PublicKey(SCHEME, raw)
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_seed(cls, seed: bytes) -> "KeyPair":
|
|
78
|
+
return cls(Ed25519PrivateKey.from_private_bytes(hashlib.sha256(seed).digest()))
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def address(self) -> str:
|
|
82
|
+
return self.public.address
|
|
83
|
+
|
|
84
|
+
def sign(self, message: bytes) -> bytes:
|
|
85
|
+
return self._priv.sign(message)
|
|
86
|
+
|
|
87
|
+
def sign_obj(self, obj: Any) -> str:
|
|
88
|
+
return self.sign(canonical(obj)).hex()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class Signed:
|
|
93
|
+
"""A message plus one signature. `body` must be JSON-serialisable."""
|
|
94
|
+
|
|
95
|
+
body: dict
|
|
96
|
+
signer: PublicKey
|
|
97
|
+
signature: str
|
|
98
|
+
|
|
99
|
+
def valid(self) -> bool:
|
|
100
|
+
return self.signer.verify(canonical(self.body), bytes.fromhex(self.signature))
|
|
101
|
+
|
|
102
|
+
def to_dict(self) -> dict:
|
|
103
|
+
return {"body": self.body, "signer": self.signer.to_dict(), "signature": self.signature}
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def from_dict(cls, d: dict) -> "Signed":
|
|
107
|
+
return cls(d["body"], PublicKey.from_dict(d["signer"]), d["signature"])
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def sign(kp: KeyPair, body: dict) -> Signed:
|
|
111
|
+
return Signed(body, kp.public, kp.sign_obj(body))
|
foliant/errors.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class FoliantError(Exception):
|
|
2
|
+
"""Base for all protocol rejections. The message is the reason a transaction fails."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PolicyViolation(FoliantError):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InvalidSignatureError(FoliantError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InvalidUpdate(FoliantError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InsufficientFunds(FoliantError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotFound(FoliantError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Unauthorized(FoliantError):
|
|
26
|
+
pass
|