msgctl 0.1.0a1__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.
- msg/__init__.py +2 -0
- msg/admin/__init__.py +0 -0
- msg/admin/backup_retirement.py +56 -0
- msg/admin/backups.py +341 -0
- msg/admin/custodial_check.py +77 -0
- msg/admin/diagnostics.py +750 -0
- msg/admin/market.py +91 -0
- msg/admin/market_check.py +341 -0
- msg/admin/money.py +322 -0
- msg/admin/preflight.py +142 -0
- msg/admin/recovery_replay.py +281 -0
- msg/admin/restore_database.py +37 -0
- msg/admin/root.py +347 -0
- msg/admin/rotation.py +197 -0
- msg/admin/token_delivery_check.py +54 -0
- msg/admin/upgrade_check.py +58 -0
- msg/application.py +163 -0
- msg/bootstrap.py +305 -0
- msg/cli.py +537 -0
- msg/client.py +725 -0
- msg/client_certificates.py +98 -0
- msg/client_content.py +43 -0
- msg/client_custodial.py +206 -0
- msg/client_market.py +104 -0
- msg/client_recovery.py +186 -0
- msg/client_secrets.py +58 -0
- msg/client_tokens.py +99 -0
- msg/client_upgrade.py +188 -0
- msg/config.py +318 -0
- msg/constants.py +11 -0
- msg/core/__init__.py +0 -0
- msg/core/batching.py +23 -0
- msg/core/codec.py +253 -0
- msg/core/contracts.py +128 -0
- msg/core/cursors.py +68 -0
- msg/core/email_address.py +33 -0
- msg/core/errors.py +22 -0
- msg/core/events.py +6 -0
- msg/core/execution_ports.py +20 -0
- msg/core/executor.py +193 -0
- msg/core/models.py +517 -0
- msg/core/packet.py +71 -0
- msg/core/permissions.py +14 -0
- msg/core/query.py +44 -0
- msg/core/read_query.py +60 -0
- msg/core/registry.py +173 -0
- msg/core/requests.py +58 -0
- msg/core/schema_policy.py +28 -0
- msg/core/schemas.py +30 -0
- msg/core/tags.py +21 -0
- msg/core/template_dsl.py +94 -0
- msg/core/text_patch.py +283 -0
- msg/core/tool_execution.py +28 -0
- msg/core/transfer.py +183 -0
- msg/daemon.py +255 -0
- msg/data/__init__.py +0 -0
- msg/data/bootstrap.json +72 -0
- msg/data/favicon.png +0 -0
- msg/data/logo-dark.svg +5 -0
- msg/data/logo.svg +5 -0
- msg/data/recovery-checkpoint.example.json +1 -0
- msg/data/recovery-checkpoint.schema.json +118 -0
- msg/data/shortcodes.json +1 -0
- msg/data/system/AGENTS.md +6 -0
- msg/data/system/rules/_index.md +17 -0
- msg/data/system/rules/auth.md +6 -0
- msg/data/system/rules/files.md +6 -0
- msg/data/system/rules/identity.md +6 -0
- msg/data/system/rules/protocol.md +6 -0
- msg/data/system/rules/read-write.md +6 -0
- msg/data/system/rules/recovery.md +6 -0
- msg/data/system/rules/security.md +10 -0
- msg/data/system/rules/topics.md +6 -0
- msg/extensions/__init__.py +1 -0
- msg/extensions/hosting.py +259 -0
- msg/extensions/keystore.py +96 -0
- msg/extensions/repositories.py +769 -0
- msg/extensions/rss.py +67 -0
- msg/extensions/ssh.py +264 -0
- msg/extensions/ssh_git.py +300 -0
- msg/extensions/tools.py +150 -0
- msg/hosting_runtime.py +138 -0
- msg/market/__init__.py +1 -0
- msg/market/arbitration.py +481 -0
- msg/market/delivery.py +338 -0
- msg/market/delivery_notifications.py +110 -0
- msg/market/delivery_targets.py +76 -0
- msg/market/email.py +100 -0
- msg/market/escrow.py +345 -0
- msg/market/orders.py +218 -0
- msg/market/policy.py +147 -0
- msg/market/rationale.py +91 -0
- msg/market/references.py +19 -0
- msg/market/targets.py +157 -0
- msg/plugins/__init__.py +28 -0
- msg/plugins/achievements.py +316 -0
- msg/plugins/batch.py +32 -0
- msg/plugins/bounty.py +366 -0
- msg/plugins/collaboration.py +261 -0
- msg/plugins/common.py +217 -0
- msg/plugins/communication.py +853 -0
- msg/plugins/content.py +855 -0
- msg/plugins/custodial_lifecycle.py +224 -0
- msg/plugins/delivery.py +230 -0
- msg/plugins/discovery.py +1266 -0
- msg/plugins/discussion.py +162 -0
- msg/plugins/extensions.py +10 -0
- msg/plugins/following.py +78 -0
- msg/plugins/hosting_capacity.py +52 -0
- msg/plugins/identity.py +1654 -0
- msg/plugins/money.py +221 -0
- msg/plugins/offers.py +183 -0
- msg/plugins/orders.py +273 -0
- msg/plugins/recovery.py +376 -0
- msg/plugins/schemas.py +5 -0
- msg/plugins/sharing.py +300 -0
- msg/plugins/store.py +249 -0
- msg/plugins/system.py +75 -0
- msg/plugins/transfer.py +249 -0
- msg/py.typed +0 -0
- msg/security/__init__.py +0 -0
- msg/security/age_keys.py +112 -0
- msg/security/authentication.py +141 -0
- msg/security/authorization.py +300 -0
- msg/security/backup_retirement.py +135 -0
- msg/security/capabilities.py +157 -0
- msg/security/certificates.py +183 -0
- msg/security/crypto.py +102 -0
- msg/security/custodial_migration.py +292 -0
- msg/security/custody_history.py +146 -0
- msg/security/network.py +81 -0
- msg/security/policy.py +67 -0
- msg/security/quarantine.py +19 -0
- msg/security/root_files.py +67 -0
- msg/security/rotation_journal.py +60 -0
- msg/security/sealed_box.py +70 -0
- msg/security/sharing_policy.py +29 -0
- msg/security/token_delivery.py +93 -0
- msg/security/vault.py +186 -0
- msg/storage/__init__.py +0 -0
- msg/storage/capacity.py +100 -0
- msg/storage/custodial_migration.py +22 -0
- msg/storage/git.py +435 -0
- msg/storage/ledger_migration.py +307 -0
- msg/storage/market_migration.py +49 -0
- msg/storage/postgres.py +663 -0
- msg/storage/query.py +39 -0
- msg/storage/read_only.py +126 -0
- msg/storage/session.py +403 -0
- msg/storage/sqlite.py +360 -0
- msg/storage/topic_event_migration.py +26 -0
- msg/storage/valkey_bus.py +45 -0
- msg/transports/__init__.py +0 -0
- msg/transports/client.py +183 -0
- msg/transports/dictionary.py +387 -0
- msg/transports/graphql.py +75 -0
- msg/transports/http.py +72 -0
- msg/transports/http_routes.py +1577 -0
- msg/transports/mcp.py +127 -0
- msg/transports/packet.py +42 -0
- msg/transports/read_tree_path.py +65 -0
- msg/transports/stdio.py +36 -0
- msg/transports/url_safety.py +123 -0
- msg/tui.py +368 -0
- msg/workers/__init__.py +1 -0
- msg/workers/effects.py +406 -0
- msg/workers/leases.py +19 -0
- msg/workers/mail.py +63 -0
- msg/workers/maintenance.py +310 -0
- msg/workers/sandbox.py +74 -0
- msg/workers/sandbox_child.py +189 -0
- msg/workers/webhook.py +160 -0
- msgctl-0.1.0a1.dist-info/METADATA +96 -0
- msgctl-0.1.0a1.dist-info/RECORD +176 -0
- msgctl-0.1.0a1.dist-info/WHEEL +4 -0
- msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
msg/market/escrow.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""One escrow release entry point, guarded transitions and append-only facts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
|
|
6
|
+
from msg.core.codec import b64, canonical, decode, digest, freeze_json, loads, parse_time, wire
|
|
7
|
+
from msg.core.errors import require
|
|
8
|
+
from msg.core.models import Event, Signature
|
|
9
|
+
from msg.core.requests import signing_bytes
|
|
10
|
+
from msg.market.policy import contract, delivery_snapshot
|
|
11
|
+
from msg.plugins.common import new_id
|
|
12
|
+
from msg.plugins.money import CURRENCY_ID, _balance, _post_transfer
|
|
13
|
+
from msg.security.crypto import verify
|
|
14
|
+
|
|
15
|
+
# Not a credential, account or public capability. Only trusted in-process market
|
|
16
|
+
# code can reach this token; ordinary signed money.transfer never receives it.
|
|
17
|
+
_ESCROW_WRITE = object()
|
|
18
|
+
TRANSITIONS = {
|
|
19
|
+
'created': {'funded', 'cancelled'},
|
|
20
|
+
'funded': {'delivered', 'refunded', 'disputed'},
|
|
21
|
+
'delivered': {'accepted', 'refunded', 'disputed'},
|
|
22
|
+
'accepted': {'settled'},
|
|
23
|
+
'disputed': {'refunded', 'settled'},
|
|
24
|
+
'cancelled': set(), 'refunded': set(), 'settled': set(),
|
|
25
|
+
}
|
|
26
|
+
OBJECTIVE_REFUNDS = frozenset({'package_missing', 'package_digest_mismatch',
|
|
27
|
+
'delivery_missing', 'delivery_digest_mismatch', 'delivery_timeout', 'buyer_cancelled'})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _post_escrow_transfer(tx, *, order, recipient, amount, actor, request_id, now,
|
|
31
|
+
receipt_signer, reference, kind):
|
|
32
|
+
"""The only place that supplies internal authority to debit OrderEscrow.
|
|
33
|
+
|
|
34
|
+
Legacy signed decisions and versioned arbitration keep their distinct
|
|
35
|
+
journals, signatures and request IDs, but share this account boundary.
|
|
36
|
+
"""
|
|
37
|
+
account = tx.one('SELECT kind,subject_id,source_id FROM ledger_accounts WHERE id=?',
|
|
38
|
+
(order['escrow_subject'],))
|
|
39
|
+
require(account == ('order_escrow', None, order['id']), 'escrow_account_mismatch')
|
|
40
|
+
require(recipient in {order['buyer'], order['seller']}, 'escrow_recipient_mismatch')
|
|
41
|
+
return _post_transfer(tx, sender=order['escrow_subject'], recipient=recipient,
|
|
42
|
+
amount=amount, actor=actor, request_id=request_id, now=now,
|
|
43
|
+
receipt_signer=receipt_signer, reference=reference, kind=kind,
|
|
44
|
+
escrow_authority=_ESCROW_WRITE)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def transition(tx, order, state, *, now, actor, request_id, reason):
|
|
48
|
+
require(state in TRANSITIONS.get(order['state'], ()), 'order_transition_invalid')
|
|
49
|
+
changed = tx.execute('UPDATE store_orders SET state=? WHERE id=? AND state=?',
|
|
50
|
+
(state, order['id'], order['state']), write=True)
|
|
51
|
+
require(changed.rowcount == 1, 'order_transition_conflict')
|
|
52
|
+
fact = {'order_id': order['id'], 'from': order['state'], 'to': state,
|
|
53
|
+
'at': wire(now), 'reason': reason, 'policy': order['dispute_policy'],
|
|
54
|
+
'actor': actor, 'request_id': request_id}
|
|
55
|
+
tx.execute('INSERT INTO order_transitions(id,order_id,body) VALUES (?,?,?)',
|
|
56
|
+
(new_id('ot'), order['id'], canonical(fact).decode()), write=True)
|
|
57
|
+
# Events deliberately contain no buyer handle, evidence, goods or address.
|
|
58
|
+
await tx.append_event(Event(id=new_id('ev'), type='orders.' + state, time=now,
|
|
59
|
+
actor=actor, subject=order['buyer'], request_id=request_id,
|
|
60
|
+
resources=(), data={'order_id': order['id'], 'state': state}))
|
|
61
|
+
order['state'] = state
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def settle(app, tx, order, *, now, actor, request_id, reason,
|
|
65
|
+
refund_minor=0, decision=None):
|
|
66
|
+
"""Validate authority *before* appending either leg of a balanced settlement.
|
|
67
|
+
|
|
68
|
+
Caller owns the transaction. Any failure rolls back both legs, the decision
|
|
69
|
+
consumption, transitions, receipts, events and request result together.
|
|
70
|
+
"""
|
|
71
|
+
contract(tx, order['id'])
|
|
72
|
+
require(tx.one('SELECT 1 FROM order_escrow_decisions WHERE order_id=?', (order['id'],)) is None,
|
|
73
|
+
'order_already_settled')
|
|
74
|
+
total = order['total_price_minor']
|
|
75
|
+
require(type(refund_minor) is int and 0 <= refund_minor <= total,
|
|
76
|
+
'settlement_amount_invalid')
|
|
77
|
+
existing = tx.one('SELECT body FROM order_settlements WHERE order_id=?', (order['id'],))
|
|
78
|
+
if existing:
|
|
79
|
+
body = loads(existing[0])
|
|
80
|
+
require(body['refund_minor'] == refund_minor and body['reason'] == reason and
|
|
81
|
+
body['decision_id'] == (decision['id'] if decision else None),
|
|
82
|
+
'order_already_settled')
|
|
83
|
+
return body['receipts']
|
|
84
|
+
if decision is not None:
|
|
85
|
+
from msg.market.arbitration import validate_decision
|
|
86
|
+
await validate_decision(app, tx, order, decision, now)
|
|
87
|
+
require(refund_minor == decision['refund_minor'], 'decision_amount_mismatch')
|
|
88
|
+
elif refund_minor == total:
|
|
89
|
+
require(reason in OBJECTIVE_REFUNDS and order['state'] in {'funded', 'delivered'},
|
|
90
|
+
'escrow_release_forbidden')
|
|
91
|
+
else:
|
|
92
|
+
require(refund_minor == 0 and order['state'] == 'accepted' and
|
|
93
|
+
reason in {'buyer_acceptance', 'managed_instant_verified'},
|
|
94
|
+
'escrow_release_forbidden')
|
|
95
|
+
require(_balance(tx, order['escrow_subject']) == total, 'escrow_balance_mismatch')
|
|
96
|
+
receipts = []
|
|
97
|
+
for suffix, recipient, amount, kind in (
|
|
98
|
+
('refund', order['buyer'], refund_minor, 'refund'),
|
|
99
|
+
('release', order['seller'], total-refund_minor, 'transfer'),
|
|
100
|
+
):
|
|
101
|
+
if amount:
|
|
102
|
+
receipts.append(_post_escrow_transfer(tx, order=order,
|
|
103
|
+
recipient=recipient, amount=amount, actor=actor,
|
|
104
|
+
request_id=f'{order["id"]}:{suffix}', now=now,
|
|
105
|
+
receipt_signer=app.receipt_signer, reference=f'order_{suffix}:{order["id"]}',
|
|
106
|
+
kind=kind))
|
|
107
|
+
final = 'refunded' if refund_minor == total else 'settled'
|
|
108
|
+
await transition(tx, order, final, now=now, actor=actor, request_id=request_id, reason=reason)
|
|
109
|
+
refs = order['receipt_refs'] + [r['body']['transaction_id'] for r in receipts]
|
|
110
|
+
tx.execute('UPDATE store_orders SET settled_at=?,receipt_refs=? WHERE id=?',
|
|
111
|
+
(wire(now), canonical(refs).decode(), order['id']), write=True)
|
|
112
|
+
statement = {'order_id': order['id'], 'refund_minor': refund_minor,
|
|
113
|
+
'release_minor': total-refund_minor, 'reason': reason,
|
|
114
|
+
'decision_id': decision['id'] if decision else None,
|
|
115
|
+
'policy': order['dispute_policy'], 'receipts': receipts, 'at': wire(now),
|
|
116
|
+
'order_facts': {name: order[name] for name in ('payment_transaction_id',
|
|
117
|
+
'payment_intent_digest', 'funded_at', 'delivered_at')},
|
|
118
|
+
'delivery_snapshot': delivery_snapshot(tx, order['id'])}
|
|
119
|
+
tx.execute('INSERT INTO order_settlements(order_id,decision_id,body) VALUES (?,?,?)',
|
|
120
|
+
(order['id'], statement['decision_id'], canonical(statement).decode()), write=True)
|
|
121
|
+
if decision:
|
|
122
|
+
tx.execute("UPDATE arbitration_cases SET state='executed' WHERE id=? AND state='decided'",
|
|
123
|
+
(decision['case_id'],), write=True)
|
|
124
|
+
order.update(receipt_refs=refs, settled_at=wire(now))
|
|
125
|
+
return receipts
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def resolve_due(app, *, limit=100):
|
|
129
|
+
"""Bounded worker tick, never called by a GET or by the ClearingEngine to buy.
|
|
130
|
+
|
|
131
|
+
Time starts at persisted creation/funding, not worker retries. Undelivered
|
|
132
|
+
services/manual ciphertext refund; subjective complaints and expired panels
|
|
133
|
+
remain held. This function has no external side effect.
|
|
134
|
+
"""
|
|
135
|
+
from msg.plugins.orders import _row
|
|
136
|
+
now = app.clock()
|
|
137
|
+
changed = []
|
|
138
|
+
async with app.metadata.transaction(write=True) as tx:
|
|
139
|
+
rows = tx.rows('''SELECT o.id,o.buyer FROM store_orders o
|
|
140
|
+
JOIN order_deadlines d ON d.order_id=o.id
|
|
141
|
+
WHERE o.state IN ('created','funded') AND d.expires_at<=?
|
|
142
|
+
ORDER BY d.expires_at,o.id LIMIT ?''', (wire(now),limit))
|
|
143
|
+
for order_id, buyer in rows:
|
|
144
|
+
order = _row(tx, order_id, buyer)
|
|
145
|
+
terms = contract(tx, order_id)
|
|
146
|
+
policy = terms['policy']['policy']
|
|
147
|
+
origin, seconds = ((order['created_at'], policy['funding_timeout_seconds'])
|
|
148
|
+
if order['state'] == 'created' else
|
|
149
|
+
(order['funded_at'], policy['delivery_timeout_seconds']))
|
|
150
|
+
if now < parse_time(origin) + timedelta(seconds=seconds):
|
|
151
|
+
continue
|
|
152
|
+
if order['state'] == 'created':
|
|
153
|
+
await transition(tx, order, 'cancelled', now=now, actor=buyer,
|
|
154
|
+
request_id='timeout:'+order_id, reason='funding_timeout')
|
|
155
|
+
else:
|
|
156
|
+
await settle(app, tx, order, now=now, actor=buyer,
|
|
157
|
+
request_id='timeout:'+order_id, reason='delivery_timeout',
|
|
158
|
+
refund_minor=order['total_price_minor'])
|
|
159
|
+
changed.append(order_id)
|
|
160
|
+
return changed
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# Published checkout contracts retain their original signed policy and journals.
|
|
164
|
+
POLICY = freeze_json({
|
|
165
|
+
'escrow_policy': 'escrow-v1', 'dispute_policy': 'dispute-v1',
|
|
166
|
+
'version': 1, 'reasons': ['buyer_cancel', 'buyer_accept'],
|
|
167
|
+
'arbitration_enabled': False, 'automatic_timeout_enabled': False,
|
|
168
|
+
'allow_split': False,
|
|
169
|
+
})
|
|
170
|
+
POLICY_DIGEST = digest(POLICY)
|
|
171
|
+
# A new policy ID, not a silent expansion of already signed escrow-v1 terms.
|
|
172
|
+
INSTANT_POLICY = freeze_json({
|
|
173
|
+
**POLICY, 'escrow_policy': 'escrow-instant-v1',
|
|
174
|
+
'reasons': ['buyer_cancel', 'buyer_accept', 'checkout_accept'],
|
|
175
|
+
'checkout_contract': 'orders.buy@2', 'requires_package_digest': True,
|
|
176
|
+
'checkout_claims_delivery': False,
|
|
177
|
+
})
|
|
178
|
+
POLICIES = (POLICY, INSTANT_POLICY)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def validate_policy(escrow_policy, dispute_policy):
|
|
182
|
+
for policy in POLICIES:
|
|
183
|
+
if (escrow_policy, dispute_policy) == (policy['escrow_policy'], policy['dispute_policy']):
|
|
184
|
+
return policy
|
|
185
|
+
require(False, 'escrow_policy_unsupported')
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def validate_decision(decision, expected, public_key, now):
|
|
189
|
+
"""A signature never substitutes for current deterministic policy checks."""
|
|
190
|
+
require(set(decision)=={'body','signature'} and decision['body']==expected,
|
|
191
|
+
'escrow_decision_mismatch')
|
|
192
|
+
require(parse_time(expected['expires_at'])>now, 'escrow_decision_expired')
|
|
193
|
+
verify(public_key,canonical(expected),decode(Signature,decision['signature']),
|
|
194
|
+
purpose='escrow-decision-v1')
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class EscrowEngine:
|
|
198
|
+
def __init__(self, app):
|
|
199
|
+
self.app=app
|
|
200
|
+
|
|
201
|
+
async def settle(self, ctx, request, tx, *, reason, order_id=None):
|
|
202
|
+
# Import handlers' read-only projections, never their write entry points.
|
|
203
|
+
from msg.plugins.delivery import _delivery, _verified_delivery
|
|
204
|
+
from msg.plugins.orders import _row, _subject
|
|
205
|
+
buyer=_subject(ctx)
|
|
206
|
+
require(order_id is None or reason == 'checkout_accept', 'escrow_decision_mismatch')
|
|
207
|
+
order=_row(tx,order_id or request.arguments['order_id'],buyer)
|
|
208
|
+
require(tx.one('SELECT 1 FROM order_contracts WHERE order_id=?', (order['id'],)) is None,
|
|
209
|
+
'order_contract_requires_v3')
|
|
210
|
+
require(tx.one('SELECT 1 FROM order_settlements WHERE order_id=?', (order['id'],)) is None,
|
|
211
|
+
'order_already_resolved')
|
|
212
|
+
require(order['buyer']==buyer, 'order_not_found')
|
|
213
|
+
policy=validate_policy(order['escrow_policy'],order['dispute_policy'])
|
|
214
|
+
require(reason in policy['reasons'], 'escrow_reason_unsupported')
|
|
215
|
+
await self.app.authorizer.require_base(ctx.principal,
|
|
216
|
+
f'{request.operation}@{request.contract_version}',buyer,tx)
|
|
217
|
+
delivery=_delivery(tx,order['id'])
|
|
218
|
+
if reason=='buyer_cancel':
|
|
219
|
+
require(request.operation=='orders.cancel' and request.contract_version==1,
|
|
220
|
+
'escrow_decision_mismatch')
|
|
221
|
+
require(order['state']=='funded' and order['delivered_at'] is None and
|
|
222
|
+
delivery is None, 'order_not_cancellable')
|
|
223
|
+
recipient,outcome,state=buyer,'refund','refunded'
|
|
224
|
+
elif reason in {'buyer_accept', 'checkout_accept'}:
|
|
225
|
+
checkout = reason == 'checkout_accept'
|
|
226
|
+
if checkout:
|
|
227
|
+
require(request.operation == 'orders.buy' and request.contract_version == 2 and
|
|
228
|
+
request.arguments.get('auto_accept') is True and
|
|
229
|
+
request.arguments['package_digest'] == order['package_digest'] and
|
|
230
|
+
order['payment_intent_digest'] == request.payload_digest,
|
|
231
|
+
'escrow_decision_mismatch')
|
|
232
|
+
else:
|
|
233
|
+
require(request.operation=='delivery.accept' and request.contract_version==1,
|
|
234
|
+
'escrow_decision_mismatch')
|
|
235
|
+
await _verified_delivery(self.app,tx,order,delivery)
|
|
236
|
+
if not checkout:
|
|
237
|
+
require(delivery['delivery_digest']==request.arguments['delivery_digest'],
|
|
238
|
+
'delivery_mismatch')
|
|
239
|
+
require(order['state']=='delivered' and order['delivered_at'] is not None and
|
|
240
|
+
delivery['state']=='prepared', 'delivery_not_acceptable')
|
|
241
|
+
require(order['delivery_target']=={'subject_id':buyer,'channel':'site'},
|
|
242
|
+
'delivery_recipient_mismatch')
|
|
243
|
+
recipient,outcome,state=order['seller'],'release','settled'
|
|
244
|
+
else:
|
|
245
|
+
require(False, 'escrow_reason_unsupported')
|
|
246
|
+
require(order['currency_id']==CURRENCY_ID and
|
|
247
|
+
_balance(tx,order['escrow_subject'])==order['total_price_minor'],
|
|
248
|
+
'escrow_balance_mismatch')
|
|
249
|
+
account=tx.one('SELECT kind,subject_id,source_id FROM ledger_accounts WHERE id=?',
|
|
250
|
+
(order['escrow_subject'],))
|
|
251
|
+
require(account==('order_escrow',None,order['id']), 'escrow_account_mismatch')
|
|
252
|
+
require(tx.one('SELECT 1 FROM order_escrow_decisions WHERE order_id=?',
|
|
253
|
+
(order['id'],)) is None, 'order_already_resolved')
|
|
254
|
+
# The ledger retains one row per (actor, request_id). Checkout has two
|
|
255
|
+
# legs, so its release uses a domain-separated internal ID, explicitly
|
|
256
|
+
# linked to the original signed request by the immutable decision.
|
|
257
|
+
ledger_request_id = (digest(('checkout-settlement-v1', buyer, request.request_id, order['id']))
|
|
258
|
+
if reason == 'checkout_accept' else request.request_id)
|
|
259
|
+
body={'id':'ed_'+digest((order['id'],request.payload_digest,reason))[7:],
|
|
260
|
+
'order_id':order['id'],'outcome':outcome,'reason':reason,
|
|
261
|
+
'recipient':recipient,'currency_id':CURRENCY_ID,
|
|
262
|
+
'amount_minor':order['total_price_minor'],
|
|
263
|
+
'policy_version':policy['version'],'policy_digest':digest(policy),
|
|
264
|
+
'order_digest':digest(order),'evidence_digest':digest(delivery),
|
|
265
|
+
'request_id':request.request_id,'request_digest':request.payload_digest,
|
|
266
|
+
'actor':buyer,'issued_at':wire(ctx.now),'expires_at':wire(request.expires_at)}
|
|
267
|
+
if reason == 'checkout_accept':
|
|
268
|
+
body['ledger_request_id'] = ledger_request_id
|
|
269
|
+
decision={'body':body,'signature':wire(self.app.receipt_signer.sign(
|
|
270
|
+
canonical(body),purpose='escrow-decision-v1'))}
|
|
271
|
+
# The current policy, source request, order and delivery are all checked
|
|
272
|
+
# under the same serialized write transaction as the debit and journal.
|
|
273
|
+
validate_decision(decision,body,self.app.receipt_signer.public_key,
|
|
274
|
+
self.app.executor.clock())
|
|
275
|
+
receipt=_post_escrow_transfer(tx,order=order,recipient=recipient,
|
|
276
|
+
amount=order['total_price_minor'],actor=buyer,request_id=ledger_request_id,
|
|
277
|
+
now=ctx.now,receipt_signer=self.app.receipt_signer,
|
|
278
|
+
reference='order_'+outcome+':'+order['id'],
|
|
279
|
+
kind='refund' if outcome=='refund' else 'transfer')
|
|
280
|
+
transaction_id=receipt['body']['transaction_id']
|
|
281
|
+
if reason == 'checkout_accept':
|
|
282
|
+
changed=tx.execute("""UPDATE store_deliveries SET receipt=?
|
|
283
|
+
WHERE order_id=? AND recipient_subject=? AND state='prepared'""",
|
|
284
|
+
(canonical(receipt).decode(),order['id'],buyer),write=True)
|
|
285
|
+
require(changed.rowcount==1, 'delivery_not_acceptable')
|
|
286
|
+
elif outcome=='release':
|
|
287
|
+
changed=tx.execute("""UPDATE store_deliveries
|
|
288
|
+
SET state='claimed',claimed_at=?,receipt=?
|
|
289
|
+
WHERE order_id=? AND recipient_subject=? AND state='prepared'""",
|
|
290
|
+
(wire(ctx.now),canonical(receipt).decode(),order['id'],buyer),write=True)
|
|
291
|
+
require(changed.rowcount==1, 'delivery_not_acceptable')
|
|
292
|
+
changed=tx.execute('''UPDATE store_orders SET state=?,settled_at=?,receipt_refs=?
|
|
293
|
+
WHERE id=? AND buyer=? AND state=?''',
|
|
294
|
+
(state,wire(ctx.now) if outcome=='release' else order['settled_at'],
|
|
295
|
+
canonical([*order['receipt_refs'],transaction_id]).decode(),
|
|
296
|
+
order['id'],buyer,order['state']),write=True)
|
|
297
|
+
require(changed.rowcount==1, 'escrow_state_conflict')
|
|
298
|
+
tx.execute('''INSERT INTO order_escrow_decisions
|
|
299
|
+
(order_id,id,body,signature,source_proof,transaction_id) VALUES (?,?,?,?,?,?)''',
|
|
300
|
+
(order['id'],body['id'],canonical(body).decode(),canonical(decision['signature']).decode(),
|
|
301
|
+
canonical({'signed_envelope':b64(signing_bytes(request)),
|
|
302
|
+
'proof':wire(request.proof)}).decode(),transaction_id),write=True)
|
|
303
|
+
return receipt,decision,delivery
|
|
304
|
+
|
|
305
|
+
async def verify_checkout_release(self, tx, order, delivery):
|
|
306
|
+
"""Validate an already committed release, without extending its execution TTL."""
|
|
307
|
+
require(order['state'] == 'settled' and order['settled_at'] is not None and
|
|
308
|
+
delivery['receipt'] is not None, 'delivery_not_acceptable')
|
|
309
|
+
policy = validate_policy(order['escrow_policy'], order['dispute_policy'])
|
|
310
|
+
require(policy == INSTANT_POLICY, 'delivery_not_acceptable')
|
|
311
|
+
row = tx.one('''SELECT body,signature,transaction_id FROM order_escrow_decisions
|
|
312
|
+
WHERE order_id=?''', (order['id'],))
|
|
313
|
+
require(row is not None, 'escrow_decision_mismatch')
|
|
314
|
+
body = loads(row[0])
|
|
315
|
+
require(body['order_id'] == order['id'] and body['reason'] == 'checkout_accept' and
|
|
316
|
+
body['outcome'] == 'release' and body['actor'] == order['buyer'] and
|
|
317
|
+
body['recipient'] == order['seller'] and body['currency_id'] == CURRENCY_ID and
|
|
318
|
+
body['amount_minor'] == order['total_price_minor'] and
|
|
319
|
+
body['policy_version'] == policy['version'] and body['policy_digest'] == digest(policy) and
|
|
320
|
+
body['request_digest'] == order['payment_intent_digest'] and
|
|
321
|
+
order['receipt_refs'] == [order['payment_transaction_id'], row[2]] and
|
|
322
|
+
order['settled_at'] == body['issued_at'], 'escrow_decision_mismatch')
|
|
323
|
+
# Reconstruct only the fields changed by the original atomic release.
|
|
324
|
+
# Mutable projections after restoration must still match its signed
|
|
325
|
+
# order and delivery snapshots, not merely buyer/amount/order ID.
|
|
326
|
+
issued_order = {**order, 'state': 'delivered', 'settled_at': None,
|
|
327
|
+
'receipt_refs': [order['payment_transaction_id']]}
|
|
328
|
+
issued_delivery = {**delivery, 'state': 'prepared', 'claimed_at': None, 'receipt': None}
|
|
329
|
+
ledger_request_id = digest(('checkout-settlement-v1', order['buyer'],
|
|
330
|
+
body['request_id'], order['id']))
|
|
331
|
+
require(body['order_digest'] == digest(issued_order) and
|
|
332
|
+
body['evidence_digest'] == digest(issued_delivery) and
|
|
333
|
+
body['ledger_request_id'] == ledger_request_id, 'escrow_decision_mismatch')
|
|
334
|
+
# The decision's deadline limited execution, not how long the buyer may
|
|
335
|
+
# acknowledge a previously paid order. Verify its original signed fact.
|
|
336
|
+
validate_decision({'body': body, 'signature': loads(row[1])}, body,
|
|
337
|
+
self.app.receipt_signer.public_key, parse_time(body['issued_at']))
|
|
338
|
+
ledger = tx.one('''SELECT debit_account,credit_account,amount_minor,currency_id,reference,receipt,actor,request_id
|
|
339
|
+
FROM money_ledger WHERE id=?''', (row[2],))
|
|
340
|
+
require(ledger is not None and ledger[:5] == (
|
|
341
|
+
order['escrow_subject'], order['seller'], order['total_price_minor'],
|
|
342
|
+
CURRENCY_ID, 'order_release:' + order['id']) and
|
|
343
|
+
loads(ledger[5]) == delivery['receipt'] and
|
|
344
|
+
ledger[6:] == (order['buyer'], ledger_request_id) and
|
|
345
|
+
_balance(tx, order['escrow_subject']) == 0, 'escrow_decision_mismatch')
|
msg/market/orders.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""New checkout contracts without changing published orders.buy@1/@2 semantics."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
|
|
6
|
+
from msg.core.codec import canonical, digest, parse_time, wire
|
|
7
|
+
from msg.core.errors import require
|
|
8
|
+
from msg.core.models import HandlerOutput
|
|
9
|
+
from msg.market.escrow import settle, transition
|
|
10
|
+
from msg.market.policy import contract, snapshot
|
|
11
|
+
from msg.market.targets import (
|
|
12
|
+
enqueue_notification, notification_view, save_target, target_for, validate_target,
|
|
13
|
+
)
|
|
14
|
+
from msg.plugins.money import CURRENCY_ID, MAX_MINOR, _post_transfer
|
|
15
|
+
from msg.plugins.orders import _order_id, _row, _subject, _view, _viewer
|
|
16
|
+
from msg.plugins.schemas import IDENTIFIER, obj
|
|
17
|
+
from msg.plugins.store import _body, _listing, _package_row
|
|
18
|
+
|
|
19
|
+
HASH = {'type': 'string', 'pattern': '^sha256:[a-f0-9]{64}$'}
|
|
20
|
+
ORDER = obj({'order_id': IDENTIFIER}, ('order_id',))
|
|
21
|
+
INTENT = {
|
|
22
|
+
'listing_id': IDENTIFIER, 'listing_revision': IDENTIFIER,
|
|
23
|
+
'quantity': {'type': 'integer', 'minimum': 1, 'maximum': 10**9},
|
|
24
|
+
'currency_id': {'const': CURRENCY_ID},
|
|
25
|
+
'total_price_minor': {'type': 'integer', 'minimum': 1, 'maximum': MAX_MINOR},
|
|
26
|
+
'email': {'type': 'string', 'minLength': 3, 'maxLength': 254},
|
|
27
|
+
}
|
|
28
|
+
INTENT_REQUIRED = ('listing_id','listing_revision','quantity','currency_id','total_price_minor')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def view(tx, order, viewer):
|
|
32
|
+
email = notification_view(tx, order)
|
|
33
|
+
projected = {**order, 'delivery_target': {**order['delivery_target'], 'email': email}}
|
|
34
|
+
result = _view(projected, viewer)
|
|
35
|
+
row = tx.one('SELECT digest FROM order_contracts WHERE order_id=?', (order['id'],))
|
|
36
|
+
if row:
|
|
37
|
+
result['order_digest'] = row[0]
|
|
38
|
+
result['contract_version'] = 3
|
|
39
|
+
locked = contract(tx, order['id'])
|
|
40
|
+
result['delivery_mode'] = locked['listing']['delivery_mode']
|
|
41
|
+
result['policy_digest'] = locked['policy']['policy_digest']
|
|
42
|
+
# Seller needs the *public encryption subkey*, not buyer's email.
|
|
43
|
+
if locked['recipient_key']:
|
|
44
|
+
result['recipient_key'] = locked['recipient_key']
|
|
45
|
+
result['email_status'] = email['state']
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def create(app, tx, ctx, request):
|
|
50
|
+
buyer, args = _subject(ctx), request.arguments
|
|
51
|
+
listing = await _listing(app, ctx, request, tx, args['listing_id'])
|
|
52
|
+
body, revision = await _body(app, tx, listing)
|
|
53
|
+
require(body['mode'] == 'sale' and body['state'] == 'active' and
|
|
54
|
+
(body['expires_at'] is None or parse_time(body['expires_at']) > ctx.now),
|
|
55
|
+
'listing_not_available')
|
|
56
|
+
require(args['listing_revision'] == listing.revision, 'listing_revision_conflict')
|
|
57
|
+
require(body['escrow_policy'] == 'escrow-v1', 'escrow_policy_unknown')
|
|
58
|
+
policy = snapshot(tx, body['dispute_policy'])
|
|
59
|
+
require(body['currency_id'] == args['currency_id'] == CURRENCY_ID, 'unsupported_currency')
|
|
60
|
+
total = body['price_minor'] * args['quantity']
|
|
61
|
+
require(0 < total <= MAX_MINOR, 'money_overflow')
|
|
62
|
+
require(total == args['total_price_minor'], 'price_changed')
|
|
63
|
+
sold = tx.one('''SELECT COALESCE(SUM(quantity),0) FROM store_orders
|
|
64
|
+
WHERE listing_id=? AND state NOT IN ('cancelled','refunded')''', (listing.id,))[0]
|
|
65
|
+
require(sold + args['quantity'] <= body['quantity'], 'quantity_unavailable')
|
|
66
|
+
require(listing.owner != buyer, 'self_purchase_forbidden')
|
|
67
|
+
require(body['item_kind'] != 'secret' or body['delivery_mode'] == 'sealed_manual',
|
|
68
|
+
'secret_requires_buyer_encryption')
|
|
69
|
+
package_id = package_revision = package_digest = None
|
|
70
|
+
if body['delivery_mode'] == 'managed_instant':
|
|
71
|
+
require(body['item_kind'] in {'file','bundle','text'}, 'order_delivery_mode_unsupported')
|
|
72
|
+
package = await _package_row(tx, body['package_ref'])
|
|
73
|
+
require(package is not None and package[1] == listing.id and package[3] == listing.owner and
|
|
74
|
+
package[5] == body['item_kind'] and package[10] == 'managed_instant',
|
|
75
|
+
'package_not_available')
|
|
76
|
+
package_id, package_revision, package_digest = package[0], package[4], package[8]
|
|
77
|
+
recipient_key = None
|
|
78
|
+
if body['delivery_mode'] == 'sealed_manual':
|
|
79
|
+
row = tx.one('''SELECT key_id,recipient,public_key FROM encryption_subkeys
|
|
80
|
+
WHERE subject=? AND is_primary=1 AND retired_at IS NULL''', (buyer,))
|
|
81
|
+
require(row is not None, 'delivery_recipient_key_required')
|
|
82
|
+
recipient_key = {'key_id': row[0], 'recipient': row[1], 'fingerprint': digest(row[2])}
|
|
83
|
+
target = await target_for(app, tx, buyer, args.get('email'))
|
|
84
|
+
order_id, now = _order_id(), wire(ctx.now)
|
|
85
|
+
escrow = 'esc_' + order_id[4:]
|
|
86
|
+
tx.execute("INSERT INTO ledger_accounts(id,kind,subject_id,source_id) VALUES (?,'order_escrow',NULL,?)",
|
|
87
|
+
(escrow, order_id), write=True)
|
|
88
|
+
tx.execute('''INSERT INTO store_orders
|
|
89
|
+
(id,buyer,seller,listing_id,listing_revision,package_id,package_revision,package_digest,
|
|
90
|
+
quantity,unit_price_minor,total_price_minor,currency_id,escrow_subject,escrow_policy,
|
|
91
|
+
dispute_policy,terms_digest,delivery_target,payment_intent_digest,payment_transaction_id,
|
|
92
|
+
state,created_at,funded_at,receipt_refs)
|
|
93
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL,'created',?,NULL,'[]')''',
|
|
94
|
+
(order_id,buyer,listing.owner,listing.id,listing.revision,package_id,package_revision,
|
|
95
|
+
package_digest,args['quantity'],body['price_minor'],total,CURRENCY_ID,escrow,
|
|
96
|
+
body['escrow_policy'],body['dispute_policy'],digest(body['terms']),canonical(target).decode(),
|
|
97
|
+
request.payload_digest,now), write=True)
|
|
98
|
+
locked = {'order_id': order_id, 'version': 3, 'buyer': buyer, 'seller': listing.owner,
|
|
99
|
+
'listing': body, 'listing_id': listing.id, 'escrow_subject': escrow,
|
|
100
|
+
'listing_revision': listing.revision,
|
|
101
|
+
'listing_digest': revision.content.digest, 'package_id': package_id,
|
|
102
|
+
'package_revision': package_revision, 'package_digest': package_digest,
|
|
103
|
+
'quantity': args['quantity'], 'total_price_minor': total,
|
|
104
|
+
'terms_digest': digest(body['terms']), 'policy': policy,
|
|
105
|
+
'recipient_key': recipient_key, 'handle_snapshot': target['handle_snapshot'],
|
|
106
|
+
'buyer_principal': wire(ctx.principal), 'created_at': now}
|
|
107
|
+
tx.execute('INSERT INTO order_contracts(order_id,body,digest) VALUES (?,?,?)',
|
|
108
|
+
(order_id,canonical(locked).decode(),digest(locked)), write=True)
|
|
109
|
+
tx.execute('INSERT INTO order_deadlines(order_id,expires_at) VALUES (?,?)',
|
|
110
|
+
(order_id, wire(ctx.now+timedelta(seconds=policy['policy']['funding_timeout_seconds']))), write=True)
|
|
111
|
+
from msg.market.email import verification_message
|
|
112
|
+
order = _row(tx, order_id, buyer)
|
|
113
|
+
await verification_message(app, tx, ctx, request, order)
|
|
114
|
+
return order
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def fund(app, tx, ctx, request, order):
|
|
118
|
+
require(order['state'] == 'created', 'order_not_fundable')
|
|
119
|
+
locked = contract(tx, order['id'])
|
|
120
|
+
require(ctx.now < parse_time(order['created_at']) + timedelta(
|
|
121
|
+
seconds=locked['policy']['policy']['funding_timeout_seconds']), 'payment_intent_expired')
|
|
122
|
+
validate_target(tx, order)
|
|
123
|
+
receipt = _post_transfer(tx, sender=order['buyer'], recipient=order['escrow_subject'],
|
|
124
|
+
amount=order['total_price_minor'], actor=order['buyer'], request_id=request.request_id,
|
|
125
|
+
now=ctx.now, receipt_signer=app.receipt_signer, reference='order_fund:'+order['id'])
|
|
126
|
+
tx.execute('''UPDATE store_orders SET payment_transaction_id=?,payment_intent_digest=?,
|
|
127
|
+
funded_at=?,receipt_refs=? WHERE id=?''', (receipt['body']['transaction_id'],
|
|
128
|
+
request.payload_digest,wire(ctx.now),canonical([receipt['body']['transaction_id']]).decode(),
|
|
129
|
+
order['id']), write=True)
|
|
130
|
+
order.update(payment_transaction_id=receipt['body']['transaction_id'],
|
|
131
|
+
payment_intent_digest=request.payload_digest, funded_at=wire(ctx.now),
|
|
132
|
+
receipt_refs=[receipt['body']['transaction_id']])
|
|
133
|
+
await transition(tx, order, 'funded', now=ctx.now, actor=order['buyer'],
|
|
134
|
+
request_id=request.request_id, reason='signed_payment_intent')
|
|
135
|
+
tx.execute('UPDATE order_deadlines SET expires_at=? WHERE order_id=?',
|
|
136
|
+
(wire(ctx.now+timedelta(seconds=locked['policy']['policy']['delivery_timeout_seconds'])), order['id']), write=True)
|
|
137
|
+
if locked['listing']['delivery_mode'] == 'managed_instant':
|
|
138
|
+
from msg.market.delivery import automatic
|
|
139
|
+
await automatic(app, tx, ctx, request, order)
|
|
140
|
+
return receipt
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def install(app, op):
|
|
144
|
+
from msg.market.email import install as install_email
|
|
145
|
+
install_email(app, op)
|
|
146
|
+
|
|
147
|
+
@op('orders.create', obj(INTENT, INTENT_REQUIRED), signature=True)
|
|
148
|
+
async def reserve(ctx, request, tx):
|
|
149
|
+
order = await create(app, tx, ctx, request)
|
|
150
|
+
return HandlerOutput(data={'order': view(tx, order, order['buyer'])})
|
|
151
|
+
|
|
152
|
+
@op('orders.buy', obj(INTENT, INTENT_REQUIRED), signature=True, version=3)
|
|
153
|
+
async def buy(ctx, request, tx):
|
|
154
|
+
order = await create(app, tx, ctx, request)
|
|
155
|
+
receipt = await fund(app, tx, ctx, request, order)
|
|
156
|
+
return HandlerOutput(data={'order': view(tx, _row(tx, order['id'], order['buyer']), order['buyer']),
|
|
157
|
+
'payment': receipt})
|
|
158
|
+
|
|
159
|
+
@op('orders.fund', obj({'order_id': IDENTIFIER, 'order_digest': HASH,
|
|
160
|
+
'total_price_minor': INTENT['total_price_minor'], 'currency_id': {'const': CURRENCY_ID}},
|
|
161
|
+
('order_id','order_digest','total_price_minor','currency_id')), signature=True)
|
|
162
|
+
async def pay(ctx, request, tx):
|
|
163
|
+
buyer = _subject(ctx)
|
|
164
|
+
order = _row(tx, request.arguments['order_id'], buyer)
|
|
165
|
+
require(order['buyer'] == buyer, 'order_not_found')
|
|
166
|
+
locked = contract(tx, order['id'])
|
|
167
|
+
require(digest(locked) == request.arguments['order_digest'] and
|
|
168
|
+
order['total_price_minor'] == request.arguments['total_price_minor'],
|
|
169
|
+
'payment_intent_mismatch')
|
|
170
|
+
payment = await fund(app, tx, ctx, request, order)
|
|
171
|
+
return HandlerOutput(data={'order': view(tx, _row(tx, order['id'], buyer), buyer),
|
|
172
|
+
'payment': payment})
|
|
173
|
+
|
|
174
|
+
@op('orders.cancel', ORDER, signature=True, version=2)
|
|
175
|
+
async def cancel(ctx, request, tx):
|
|
176
|
+
buyer = _subject(ctx)
|
|
177
|
+
order = _row(tx, request.arguments['order_id'], buyer)
|
|
178
|
+
require(order['buyer'] == buyer, 'order_not_found')
|
|
179
|
+
if order['state'] == 'created':
|
|
180
|
+
await transition(tx, order, 'cancelled', now=ctx.now, actor=buyer,
|
|
181
|
+
request_id=request.request_id, reason='buyer_cancelled')
|
|
182
|
+
elif order['state'] not in {'cancelled', 'refunded'}:
|
|
183
|
+
require(order['state'] == 'funded' and order['delivered_at'] is None,
|
|
184
|
+
'order_not_cancellable')
|
|
185
|
+
await settle(app, tx, order, now=ctx.now, actor=buyer, request_id=request.request_id,
|
|
186
|
+
reason='buyer_cancelled', refund_minor=order['total_price_minor'])
|
|
187
|
+
return HandlerOutput(data={'order': view(tx, order, buyer)})
|
|
188
|
+
|
|
189
|
+
@op('orders.email_bind', obj({'order_id': IDENTIFIER, 'email': INTENT['email']},
|
|
190
|
+
('order_id','email')), signature=True)
|
|
191
|
+
async def email_bind(ctx, request, tx):
|
|
192
|
+
buyer = _subject(ctx)
|
|
193
|
+
order = _row(tx, request.arguments['order_id'], buyer)
|
|
194
|
+
require(order['buyer'] == buyer, 'order_not_found')
|
|
195
|
+
contract(tx, order['id'])
|
|
196
|
+
old = order['delivery_target'].get('email', {})
|
|
197
|
+
require(order['state'] in {'created','funded','delivered','settled'}, 'order_closed')
|
|
198
|
+
require(order['delivered_at'] is None or
|
|
199
|
+
(old.get('endpoint_id') is None and
|
|
200
|
+
old.get('address_snapshot') == request.arguments['email']),
|
|
201
|
+
'delivery_target_locked')
|
|
202
|
+
target = await target_for(app, tx, buyer, request.arguments['email'])
|
|
203
|
+
target['handle_snapshot'] = order['delivery_target']['handle_snapshot']
|
|
204
|
+
require(target['email'].get('endpoint_id') is not None, 'email_not_verified')
|
|
205
|
+
save_target(tx, order, target)
|
|
206
|
+
validate_target(tx, order, email=True)
|
|
207
|
+
if order['delivered_at']:
|
|
208
|
+
await enqueue_notification(app, tx, ctx, request, order)
|
|
209
|
+
return HandlerOutput(data={'order': view(tx, order, buyer)})
|
|
210
|
+
|
|
211
|
+
@op('orders.contract', ORDER, effect='read')
|
|
212
|
+
async def get_contract(ctx, request, tx):
|
|
213
|
+
viewer = _viewer(ctx)
|
|
214
|
+
order = _row(tx, request.arguments['order_id'], viewer)
|
|
215
|
+
locked = contract(tx, order['id'])
|
|
216
|
+
# Neither endpoint nor credential ceilings are part of seller projections.
|
|
217
|
+
return HandlerOutput(data={'order': view(tx, order, viewer), 'contract': {
|
|
218
|
+
k:v for k,v in locked.items() if k not in {'buyer_principal','handle_snapshot'}}})
|