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 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