@fitzdoud/valun 0.0.0-stage → 0.3.0
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.
- package/LICENSE +21 -0
- package/README.md +259 -2
- package/package.json +53 -4
- package/src/agent.js +590 -0
- package/src/basis.js +243 -0
- package/src/blake2b.js +137 -0
- package/src/bytes.js +60 -0
- package/src/client.js +565 -0
- package/src/dns.d.ts +4 -0
- package/src/dns.js +12 -0
- package/src/errors.js +18 -0
- package/src/file-store.d.ts +16 -0
- package/src/file-store.js +78 -0
- package/src/index.d.ts +650 -0
- package/src/index.js +24 -0
- package/src/measure.js +214 -0
- package/src/protocol.js +167 -0
- package/src/py.js +318 -0
- package/src/random.js +24 -0
- package/src/sandbox.js +131 -0
- package/src/schemas.js +157 -0
- package/src/sha256.js +52 -0
- package/src/tools.js +112 -0
package/src/agent.js
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
// The nine Valun agent tools, independent of any agent framework (port of python/src/valun/agent.py).
|
|
2
|
+
//
|
|
3
|
+
// An adapter supplies a Client (whose store holds the participant's key, token and non-secret state), a few settings and
|
|
4
|
+
// a sentence saying where approvals happen; this module does the protocol work: it builds and verifies what is signed
|
|
5
|
+
// (through the Client), measures deliveries against the agreed terms, and explains results in plain words. Results,
|
|
6
|
+
// error payloads and approval texts are the Python reference's, word for word (test/agent-parity.test.js replays
|
|
7
|
+
// scenarios recorded from the Python code). Every tool result is a plain JSON-compatible object.
|
|
8
|
+
//
|
|
9
|
+
// Secrets never leave the store: no tool returns, logs or echoes the signing key or the agent token.
|
|
10
|
+
// Approvals fail closed: isCommitment() is true unless the action is exactly a harmless one, valun_review_delivery
|
|
11
|
+
// refuses any decision but exactly 'accept' or 'dispute' (bad_decision) before connecting, and bind() pins the terms
|
|
12
|
+
// hash or deliverable hash an approval is about, so what is signed is what the owner saw.
|
|
13
|
+
|
|
14
|
+
import {ValunError} from './errors.js';
|
|
15
|
+
import {measure} from './measure.js';
|
|
16
|
+
import {checkDelivery, extractText} from './sandbox.js';
|
|
17
|
+
import * as schemas from './schemas.js';
|
|
18
|
+
import {
|
|
19
|
+
PyError, cpLength, cpSlice, dictCopy, get, int, isDict, isoAwareMs, item, items, iterate, lower, or, pyEq, repr, slice, str, strip, truthy,
|
|
20
|
+
typeName,
|
|
21
|
+
} from './py.js';
|
|
22
|
+
|
|
23
|
+
export const SETTLEMENT_NOTE = 'Issuing a credit note creates an outstanding obligation (the payer owes the payee that many PC). '
|
|
24
|
+
+ 'Nothing is settled in the Valun pilot: there is no redemption, reserve or clearing yet.';
|
|
25
|
+
export const NOT_SETTLED = 'A credit note is an outstanding promise to pay, not a payment: nothing is settled in the pilot. '
|
|
26
|
+
+ 'PC are synthetic units with no monetary value.';
|
|
27
|
+
export const HONEST_LABELS = Object.freeze({
|
|
28
|
+
what_basis_records: 'When a payer accepts a delivery, its agent signs a Basis note and the pilot Basis tracker '
|
|
29
|
+
+ 'records it: the payer key, the payee key, the running total that payer owes that payee '
|
|
30
|
+
+ '(one cumulative total per key pair) and a timestamp. The tracker has no blockchain connection '
|
|
31
|
+
+ '(notes stay local_only) and no reserve backs the notes.',
|
|
32
|
+
outstanding_promise: 'An issued credit note is an outstanding promise by the payer to pay that many PC. Nothing in '
|
|
33
|
+
+ 'the pilot settles it: there is no redemption, reserve, clearing or netting. PC are synthetic '
|
|
34
|
+
+ 'pilot units with no monetary value.',
|
|
35
|
+
third_party: 'Supported: paying a third party with your OWN credit note; the third party may require that your '
|
|
36
|
+
+ 'claims on the tracker cover it (min_payer_coverage_pct). Not supported: transferring, assigning or '
|
|
37
|
+
+ 'endorsing a note you hold to someone else, and clearing or netting between participants.',
|
|
38
|
+
});
|
|
39
|
+
export const REAL_VS_SIMULATED = Object.freeze({
|
|
40
|
+
real: "signatures by each participant's own key; Valun's acceptance and approval records; credit notes held by a "
|
|
41
|
+
+ 'real Basis tracker (read back and reconciled by Valun)',
|
|
42
|
+
simulated: 'PC (pilot credits) have no monetary value; the Basis tracker has no blockchain connection (notes are '
|
|
43
|
+
+ 'local_only, never on-chain); settlement does not exist in the pilot',
|
|
44
|
+
not_proven: 'that delivered work is good (Valun records statements, it does not judge work); that any participant '
|
|
45
|
+
+ 'will ever honour a credit note',
|
|
46
|
+
});
|
|
47
|
+
/** valun_set_limits argument -> [server field, kind]. Caps can only be lowered below the owner's mandate, floors raised. */
|
|
48
|
+
export const LIMIT_FIELDS = Object.freeze({
|
|
49
|
+
max_per_deal_as_payer: Object.freeze(['max_issue_per_deal', 'cap']), max_total_owed: Object.freeze(['max_issue_outstanding', 'cap']),
|
|
50
|
+
max_per_deal_as_payee: Object.freeze(['max_accept_per_deal', 'cap']), max_claim_per_payer: Object.freeze(['max_accept_per_issuer', 'cap']),
|
|
51
|
+
min_price_as_payee: Object.freeze(['min_price_as_payee', 'floor']), min_payer_coverage_pct: Object.freeze(['min_payer_coverage_pct', 'floor']),
|
|
52
|
+
});
|
|
53
|
+
export const TOOL_NAMES = Object.freeze(schemas.ALL.map(t => t.name));
|
|
54
|
+
export const READ_ONLY_TOOLS = Object.freeze(['valun_status', 'valun_directory', 'valun_inbox', 'valun_deal']);
|
|
55
|
+
export const DEFAULT_SETUP_HINT = 'run `valun join` (or `valun enroll`) in a terminal';
|
|
56
|
+
/** Where approvals happen, as the Python MCP server and CLI tell the model (python/src/valun/mcp_server.py). */
|
|
57
|
+
export const APPROVAL_TEXT = Object.freeze({
|
|
58
|
+
ask: 'Before you sign an offer, counter-offer, acceptance or credit note, your owner is asked through this app and '
|
|
59
|
+
+ 'sees exactly what you would sign; if they decline, nothing is signed.',
|
|
60
|
+
'client-prompt': 'Your app asks your owner before each Valun tool that signs a commitment (if your owner has not '
|
|
61
|
+
+ 'allowed it permanently).',
|
|
62
|
+
console: 'Every commitment you sign waits for your owner in the Valun owner console before it takes effect.',
|
|
63
|
+
none: "You act on your own within your owner's limits; nobody is asked before you sign.",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const DEAL_FIELDS = ['id', 'status', 'your_role', 'counterparty', 'next_actions', 'versions', 'acceptance', 'agreement', 'decline',
|
|
67
|
+
'completion', 'dispute', 'cancellation', 'approvals', 'labels'];
|
|
68
|
+
const LIMITS_SHOWN = Object.entries(LIMIT_FIELDS);
|
|
69
|
+
|
|
70
|
+
function pyCompare(a, b, op) {
|
|
71
|
+
const num = v => typeof v === 'number' || typeof v === 'boolean';
|
|
72
|
+
if (!num(a) || !num(b)) throw new PyError('TypeError', `'${op}' not supported between instances of '${typeName(a)}' and '${typeName(b)}'`);
|
|
73
|
+
return op === '>' ? Number(a) > Number(b) : Number(a) < Number(b);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Map valun_set_limits arguments to the server's /limits body. */
|
|
77
|
+
export function limitsBody(args) {
|
|
78
|
+
const body = {};
|
|
79
|
+
for (const [arg, [field]] of LIMITS_SHOWN) {
|
|
80
|
+
if (get(args, arg) !== null) body[field] = int(item(args, arg));
|
|
81
|
+
}
|
|
82
|
+
for (const arg of iterate(or(get(args, 'clear'), []))) {
|
|
83
|
+
if (Array.isArray(arg) || isDict(arg)) throw new PyError('TypeError', `cannot use '${typeName(arg)}' as a dict key (unhashable type: '${typeName(arg)}')`);
|
|
84
|
+
if (typeof arg === 'string' && Object.hasOwn(LIMIT_FIELDS, arg)) body[LIMIT_FIELDS[arg][0]] = null;
|
|
85
|
+
}
|
|
86
|
+
if (get(args, 'goals') !== null) body.notes = str(item(args, 'goals'));
|
|
87
|
+
return body;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* True when a /limits body would loosen a limit the agent recorded before (a higher cap, a lower floor, or removing
|
|
92
|
+
* one). Setting a limit for the first time is never looser than the owner's mandate (the server refuses that).
|
|
93
|
+
*/
|
|
94
|
+
export function loosens(current, body) {
|
|
95
|
+
const kinds = Object.fromEntries(Object.values(LIMIT_FIELDS));
|
|
96
|
+
for (const [field, next] of Object.entries(body)) {
|
|
97
|
+
if (!Object.hasOwn(kinds, field) || get(current, field) === null) continue;
|
|
98
|
+
const old = item(current, field);
|
|
99
|
+
if (next === null || (kinds[field] === 'cap' && pyCompare(next, old, '>')) || (kinds[field] === 'floor' && pyCompare(next, old, '<'))) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Has this participant's agent made a call to Valun within the last `hours`? A participant that joined within that
|
|
108
|
+
* window counts as active even before its agent's first call (it is new, not idle). Times without a UTC offset, or
|
|
109
|
+
* not ISO 8601, count as not recent (as in Python, where comparing them fails).
|
|
110
|
+
*/
|
|
111
|
+
export function recentlyActive(lastSeen, hours = 24, joinedAt = null, nowMs = Date.now()) {
|
|
112
|
+
const within = iso => {
|
|
113
|
+
const t = isoAwareMs(str(iso).replaceAll('Z', '+00:00'));
|
|
114
|
+
return t !== null && nowMs - t < hours * 3_600_000;
|
|
115
|
+
};
|
|
116
|
+
return within(lastSeen) || within(joinedAt);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The agreed (or current) service text: where numeric requirements such as '300-500 words' are written. */
|
|
120
|
+
export function termsText(d) {
|
|
121
|
+
const t = or(get(or(get(or(get(d, 'current'), {}), 'terms'), {}), 'service'), {});
|
|
122
|
+
return ['title', 'description', 'deliverable'].map(k => str(or(get(t, k), ''))).join(' ');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isValunError(e) {
|
|
126
|
+
return e instanceof ValunError || (e instanceof Error && e.name === 'ValunError' && typeof e.code === 'string');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A ValunError (or any exception) as a plain object an agent can act on. Never includes secrets. */
|
|
130
|
+
export function errorPayload(e, setupHint = DEFAULT_SETUP_HINT) {
|
|
131
|
+
if (isValunError(e)) {
|
|
132
|
+
const out = {error: e.code, message: e.message};
|
|
133
|
+
const extra = isDict(e.extra) ? e.extra : {};
|
|
134
|
+
for (const k of ['rule', 'mandate_version', 'measured']) if (Object.hasOwn(extra, k)) out[k] = extra[k];
|
|
135
|
+
if (e.code === 'mandate_exceeded') {
|
|
136
|
+
out.what_this_means = "Your owner's mandate does not allow this. Nothing was recorded. Do not try to work around "
|
|
137
|
+
+ 'it; tell your owner if you think the limit should change.';
|
|
138
|
+
}
|
|
139
|
+
if (e.code === 'offer_not_authorized') {
|
|
140
|
+
out.what_this_means = "The other side's owner no longer allows this offer (their mandate changed or is used up). "
|
|
141
|
+
+ 'Nothing was recorded. You may decline it or wait for a new offer.';
|
|
142
|
+
}
|
|
143
|
+
if (e.code === 'key_changed' || e.code === 'payee_no_key') {
|
|
144
|
+
out.what_this_means = "A party's agent key was revoked or replaced by its owner. Offers that name the old key can "
|
|
145
|
+
+ 'no longer be agreed; agreed deals continue once the party has a new key.';
|
|
146
|
+
}
|
|
147
|
+
if (['unauthorized', 'not_connected', 'not_configured'].includes(e.code)) {
|
|
148
|
+
out.what_this_means = `This agent is not connected to Valun. Its owner must ${setupHint}.`;
|
|
149
|
+
}
|
|
150
|
+
if (e.code === 'owner_declined') out.what_this_means = 'Your owner did not approve this commitment. Nothing was signed or sent.';
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
if (e instanceof Error) return {error: 'internal_error', message: `${e.name}: ${e.message}`};
|
|
154
|
+
return {error: 'internal_error', message: `Error: ${String(e)}`};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The nine Valun tools over one participant's Client.
|
|
159
|
+
*
|
|
160
|
+
* options:
|
|
161
|
+
* approvals one sentence telling the model where commitments are approved (adapter-specific; APPROVAL_TEXT has
|
|
162
|
+
* the Python adapters' sentences). valunTools() fills it in when it is empty.
|
|
163
|
+
* checkLinkHosts look up link host names when measuring deliveries (default true; no page is fetched). Needs
|
|
164
|
+
* resolveHost; without one the measurement says the hosts were not checked.
|
|
165
|
+
* resolveHost async host => 'public' | 'private' | 'not_found' (Node: resolveHost from "@fitzdoud/valun/dns").
|
|
166
|
+
* hostTimeoutMs how long all host lookups of one measurement may take together (default 4000, as in Python).
|
|
167
|
+
* setupHint what the owner must do to connect the agent (default: run `valun join` (or `valun enroll`) ...).
|
|
168
|
+
* now clock in ms (test hook; the Client has its own).
|
|
169
|
+
*/
|
|
170
|
+
export class ValunAgent {
|
|
171
|
+
#client;
|
|
172
|
+
#now;
|
|
173
|
+
|
|
174
|
+
constructor(client, options = {}) {
|
|
175
|
+
if (!client || typeof client.deal !== 'function' || typeof client.complete !== 'function') {
|
|
176
|
+
throw new TypeError('ValunAgent needs a valun Client');
|
|
177
|
+
}
|
|
178
|
+
const {approvals = '', checkLinkHosts = true, resolveHost = null, hostTimeoutMs = 4000, setupHint = null, now = null} = options;
|
|
179
|
+
if (resolveHost !== null && typeof resolveHost !== 'function') throw new TypeError('resolveHost must be a function');
|
|
180
|
+
this.#client = client;
|
|
181
|
+
this.#now = now || (() => Date.now());
|
|
182
|
+
this.approvals = approvals;
|
|
183
|
+
this.checkLinkHosts = checkLinkHosts;
|
|
184
|
+
this.resolveHost = resolveHost;
|
|
185
|
+
this.hostTimeoutMs = hostTimeoutMs;
|
|
186
|
+
this.setupHint = setupHint || DEFAULT_SETUP_HINT;
|
|
187
|
+
this.handlers = Object.freeze({
|
|
188
|
+
valun_status: a => this.status(a), valun_directory: a => this.directory(a), valun_inbox: a => this.inbox(a),
|
|
189
|
+
valun_deal: a => this.deal(a), valun_propose: a => this.propose(a), valun_respond: a => this.respond(a),
|
|
190
|
+
valun_deliver: a => this.deliver(a), valun_review_delivery: a => this.review(a), valun_set_limits: a => this.setLimits(a),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------- plumbing ----------
|
|
195
|
+
/** The Client, after checking that it is enrolled (not_connected otherwise). */
|
|
196
|
+
client(requireConnection = true) {
|
|
197
|
+
const c = this.#client;
|
|
198
|
+
if (requireConnection && (!c.connected || !c.participantId)) {
|
|
199
|
+
throw new ValunError('not_connected', `not enrolled: the owner must ${this.setupHint}`);
|
|
200
|
+
}
|
|
201
|
+
return c;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Run one tool. Resolves to a plain object; rejects with a ValunError (use errorPayload to turn it into a result). */
|
|
205
|
+
async call(name, args = null) {
|
|
206
|
+
if (typeof name !== 'string' || !Object.hasOwn(this.handlers, name)) {
|
|
207
|
+
throw new ValunError('unknown_tool', `no Valun tool called ${repr(name)}`);
|
|
208
|
+
}
|
|
209
|
+
return this.handlers[name](truthy(args) ? args : {});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Like call(), but never rejects: errors come back as {error: ...}. */
|
|
213
|
+
async callSafe(name, args = null) {
|
|
214
|
+
try {
|
|
215
|
+
return await this.call(name, args);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
return errorPayload(e, this.setupHint);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#measureOptions(checkHosts) {
|
|
222
|
+
return {checkHosts, resolveHost: this.resolveHost, timeoutMs: this.hostTimeoutMs};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async #resolve(c, who) {
|
|
226
|
+
who = lower(strip(or(who, '')));
|
|
227
|
+
for (const p of iterate(await c.directory())) {
|
|
228
|
+
if (item(p, 'id') === who || item(p, 'handle') === who) return p;
|
|
229
|
+
}
|
|
230
|
+
throw new ValunError('unknown_participant', `no participant called ${repr(who)}; use valun_directory`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------- approvals ----------
|
|
234
|
+
/** Does this call sign something that commits the owner (or loosen a limit)? Adapters ask the owner first. */
|
|
235
|
+
async isCommitment(name, args = null) {
|
|
236
|
+
args = or(args, {});
|
|
237
|
+
if (name === 'valun_propose') return true;
|
|
238
|
+
// Fail closed: only the explicitly harmless actions skip the owner (a misspelt "Accept" must never skip them).
|
|
239
|
+
if (name === 'valun_respond') return !['decline', 'withdraw'].includes(get(args, 'action'));
|
|
240
|
+
if (name === 'valun_review_delivery') return get(args, 'decision') !== 'dispute';
|
|
241
|
+
if (name === 'valun_set_limits') {
|
|
242
|
+
try {
|
|
243
|
+
const me = await this.client().me();
|
|
244
|
+
return loosens(or(get(me, 'agent_limits'), {}), limitsBody(args));
|
|
245
|
+
} catch {
|
|
246
|
+
return true; // when in doubt, ask the owner
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Pin what the owner is about to approve: an acceptance to the current terms hash, a delivery acceptance to the
|
|
254
|
+
* current deliverable hash, unless the agent already passed them. Adapters call this BEFORE asking the owner and sign
|
|
255
|
+
* with the returned arguments, so the signature covers exactly what the owner saw (the client refuses a changed offer
|
|
256
|
+
* or delivery instead of silently signing it). Best effort: returns a copy of the arguments, pinned where it could.
|
|
257
|
+
*/
|
|
258
|
+
async bind(name, args = null) {
|
|
259
|
+
const out = dictCopy(or(args, {}));
|
|
260
|
+
try {
|
|
261
|
+
if (name === 'valun_respond' && get(out, 'action') === 'accept' && !truthy(get(out, 'expected_terms_hash'))) {
|
|
262
|
+
const cur = or(get(await this.client().deal(item(out, 'deal_id')), 'current'), {});
|
|
263
|
+
if (truthy(get(cur, 'terms_hash'))) out.expected_terms_hash = item(cur, 'terms_hash');
|
|
264
|
+
}
|
|
265
|
+
if (name === 'valun_review_delivery' && get(out, 'decision') === 'accept' && !truthy(get(out, 'expected_deliverable_sha256'))) {
|
|
266
|
+
const dl = or(get(await this.client().deal(item(out, 'deal_id')), 'delivery'), {});
|
|
267
|
+
if (truthy(get(dl, 'sha256'))) out.expected_deliverable_sha256 = item(dl, 'sha256');
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
// binding is best effort; the call itself reports real problems
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Text for an approval prompt: exactly what the agent is about to sign, and what it means. */
|
|
276
|
+
async describeCommitment(name, args = null) {
|
|
277
|
+
args = or(args, {});
|
|
278
|
+
try {
|
|
279
|
+
const c = this.client();
|
|
280
|
+
if (name === 'valun_set_limits') {
|
|
281
|
+
const me = await c.me();
|
|
282
|
+
const cur = or(get(me, 'agent_limits'), {}), mandate = item(me, 'mandate'), body = limitsBody(args);
|
|
283
|
+
const names = Object.fromEntries(LIMITS_SHOWN.map(([arg, [field]]) => [field, arg]));
|
|
284
|
+
const lines = ['Valun - LOOSEN limits recorded from your instructions (the Valun server enforces them):'];
|
|
285
|
+
for (const [field, next] of Object.entries(body)) {
|
|
286
|
+
if (Object.hasOwn(names, field)) {
|
|
287
|
+
lines.push(` ${names[field]}: ${str(get(cur, field, get(mandate, field)))} -> ${next !== null ? str(next) : 'removed'} (your owner mandate: ${str(get(mandate, field))})`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
lines.push('Approve only if you asked your agent for this.');
|
|
291
|
+
return lines.join('\n');
|
|
292
|
+
}
|
|
293
|
+
if (name === 'valun_propose') {
|
|
294
|
+
const role = get(args, 'role'), price = get(args, 'price'), who = get(args, 'counterparty');
|
|
295
|
+
return [
|
|
296
|
+
`Valun - sign a NEW OFFER to ${str(who)}:`,
|
|
297
|
+
` service: ${str(get(args, 'title'))}`,
|
|
298
|
+
` deliverable: ${str(get(args, 'deliverable'))}`,
|
|
299
|
+
` price: ${str(price)} PC; you are the ` + (role === 'payer' ? 'payer' : 'payee (you do the work)'),
|
|
300
|
+
` deliver within ${str(or(get(args, 'deliver_within_hours'), 48))} h; the offer stays open ${str(or(get(args, 'offer_valid_hours'), 24))} h`,
|
|
301
|
+
`If ${str(who)} accepts, the deal is binding without asking you again: `
|
|
302
|
+
+ (role === 'payer' ? `after you accept their delivery you will owe them ${str(price)} PC (your credit note).`
|
|
303
|
+
: `you must deliver; they would then owe you ${str(price)} PC (their credit note).`),
|
|
304
|
+
NOT_SETTLED].join('\n');
|
|
305
|
+
}
|
|
306
|
+
const d = await c.deal(get(args, 'deal_id'));
|
|
307
|
+
const cur = or(get(d, 'current'), {});
|
|
308
|
+
const t = or(get(cur, 'terms'), {}), who = get(or(get(d, 'counterparty'), {}), 'handle'), role = get(d, 'your_role');
|
|
309
|
+
const price = get(or(get(t, 'price'), {}), 'amount'), svc = or(get(t, 'service'), {});
|
|
310
|
+
if (name === 'valun_respond' && get(args, 'action') === 'accept') {
|
|
311
|
+
return [
|
|
312
|
+
`Valun - ACCEPT ${str(who)}'s offer (deal ${str(item(d, 'id'))}, version ${str(get(cur, 'version'))}):`,
|
|
313
|
+
` service: ${str(get(svc, 'title'))}`,
|
|
314
|
+
` deliverable: ${str(get(svc, 'deliverable'))}`,
|
|
315
|
+
` price: ${str(price)} PC; you are the ${str(role)}; deliver by ${str(get(t, 'deliver_by'))}`,
|
|
316
|
+
` terms hash ${str(slice(or(get(args, 'expected_terms_hash'), or(get(cur, 'terms_hash'), '')), 16))}... (rechecked by your Valun client before signing)`
|
|
317
|
+
+ (!truthy(get(args, 'expected_terms_hash')) || pyEq(item(args, 'expected_terms_hash'), get(cur, 'terms_hash')) ? ''
|
|
318
|
+
: '\n WARNING: the offer changed after your agent read it; signing these terms will be refused'),
|
|
319
|
+
'This makes the deal binding on both sides: '
|
|
320
|
+
+ (role === 'payer' ? `after you accept the delivery you will owe ${str(who)} ${str(price)} PC.`
|
|
321
|
+
: `you must deliver; ${str(who)} would then owe you ${str(price)} PC.`),
|
|
322
|
+
NOT_SETTLED].join('\n');
|
|
323
|
+
}
|
|
324
|
+
if (name === 'valun_respond' && get(args, 'action') === 'counter') {
|
|
325
|
+
const changes = ['price', 'title', 'description', 'deliverable', 'deliver_within_hours']
|
|
326
|
+
.filter(k => { const v = get(args, k); return v !== null && v !== ''; })
|
|
327
|
+
.map(k => `${k} ${str(item(args, k))}`);
|
|
328
|
+
return [
|
|
329
|
+
`Valun - sign a COUNTER-OFFER to ${str(who)} (deal ${str(item(d, 'id'))}), replacing their version ${str(get(cur, 'version'))} at ${str(price)} PC:`,
|
|
330
|
+
' changes: ' + (changes.join(', ') || 'none'),
|
|
331
|
+
`If ${str(who)} accepts it, the deal is binding without asking you again. You are the ${str(role)}.`,
|
|
332
|
+
NOT_SETTLED].join('\n');
|
|
333
|
+
}
|
|
334
|
+
if (name === 'valun_review_delivery') {
|
|
335
|
+
const dl = or(get(d, 'delivery'), {});
|
|
336
|
+
const m = await measure(or(get(dl, 'content'), ''), termsText(d), {checkHosts: false});
|
|
337
|
+
const facts = m.checks_against_agreed_terms.map(x => `${x.requirement}: ${x.measured} (${x.met ? 'met' : 'NOT met'})`);
|
|
338
|
+
const exact = checkDelivery(t, or(get(dl, 'content'), ''));
|
|
339
|
+
if (truthy(exact) && truthy(exact.checked)) {
|
|
340
|
+
facts.push('exact result: ' + (truthy(exact.exact_result_matches) ? 'matches the agreed text'
|
|
341
|
+
: `DOES NOT MATCH (differs in ${or(exact.differs_in, []).join(', ')})`));
|
|
342
|
+
}
|
|
343
|
+
return [
|
|
344
|
+
`Valun - ACCEPT ${str(who)}'s delivery (deal ${str(item(d, 'id'))}) and ISSUE A CREDIT NOTE of ${str(price)} PC to ${str(who)}:`,
|
|
345
|
+
` delivered: ${str(or(get(dl, 'title'), '(untitled)'))}, ${str(get(dl, 'bytes'))} bytes, sha256 ${str(slice(or(get(dl, 'sha256'), ''), 16))}...`,
|
|
346
|
+
' measured: ' + (facts.join('; ') || `${m.word_count} words, ${m.links.length} links`),
|
|
347
|
+
`Your agent signs a Basis note; the pilot tracker then records that you owe ${str(who)} ${str(price)} PC more.`,
|
|
348
|
+
NOT_SETTLED].join('\n');
|
|
349
|
+
}
|
|
350
|
+
} catch {
|
|
351
|
+
// the prompt must still appear
|
|
352
|
+
}
|
|
353
|
+
return `Valun: sign a commitment (${str(name)}).`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---------- tools ----------
|
|
357
|
+
async status() {
|
|
358
|
+
const c = this.client();
|
|
359
|
+
const me = await c.me(), info = await c.info();
|
|
360
|
+
const recorded = or(get(me, 'agent_limits'), {});
|
|
361
|
+
let approvals = or(this.approvals, 'Commitments are signed without asking your owner first.');
|
|
362
|
+
approvals += ` The server also holds commitments priced above ${str(get(item(me, 'mandate'), 'approve_above'))} PC for the owner console.`;
|
|
363
|
+
const inForce = or(get(me, 'effective_limits'), item(me, 'mandate'));
|
|
364
|
+
return {
|
|
365
|
+
connected: true, server: item(c.state, 'server'), network: item(info, 'network'),
|
|
366
|
+
unit: item(info, 'unit') + ' (pilot credits, no monetary value)',
|
|
367
|
+
you: item(me, 'participant'), objective_from_your_owner: or(get(item(me, 'mandate'), 'objective'), '(none set)'),
|
|
368
|
+
goals_your_owner_told_you: or(get(recorded, 'notes'), '(none recorded yet: ask your owner, then use valun_set_limits)'),
|
|
369
|
+
limits_you_recorded: Object.fromEntries(items(recorded).filter(([k]) => k !== 'notes' && k !== 'set_at')),
|
|
370
|
+
limits_in_force: Object.fromEntries(LIMITS_SHOWN.map(([arg, [field]]) => [arg, get(inForce, field)])),
|
|
371
|
+
mandate: Object.fromEntries(items(item(me, 'mandate')).filter(([k]) => k !== 'objective')), mandate_version: item(me, 'mandate_version'),
|
|
372
|
+
approvals, position: item(me, 'position'), authority: item(me, 'authority'), settlement: SETTLEMENT_NOTE,
|
|
373
|
+
honest_labels: {...HONEST_LABELS}, real_vs_simulated: {...REAL_VS_SIMULATED},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async setLimits(a) {
|
|
378
|
+
const c = this.client();
|
|
379
|
+
const body = limitsBody(a);
|
|
380
|
+
if (!truthy(body)) throw new ValunError('nothing_to_record', "give at least one limit, a limit to clear, or your owner's goals");
|
|
381
|
+
const r = await c.setLimits(body);
|
|
382
|
+
const eff = or(get(r, 'effective_limits'), {});
|
|
383
|
+
return {recorded: get(r, 'agent_limits'), limits_in_force: Object.fromEntries(LIMITS_SHOWN.map(([arg, [field]]) => [arg, get(eff, field)])),
|
|
384
|
+
what_this_means: "The Valun server now enforces these limits at every action, together with your owner's mandate "
|
|
385
|
+
+ '(the stricter of the two applies). Only your owner can raise the mandate.'};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async directory(a) {
|
|
389
|
+
const c = this.client();
|
|
390
|
+
const q = lower(or(get(a, 'query'), ''));
|
|
391
|
+
const out = [];
|
|
392
|
+
for (const p of iterate(await c.directory())) {
|
|
393
|
+
if (item(p, 'id') === c.participantId || item(p, 'status') !== 'active') continue;
|
|
394
|
+
const text = lower(['handle', 'display_name', 'about', 'services'].map(k => str(or(get(p, k), ''))).join(' '));
|
|
395
|
+
if (q && !text.includes(q)) continue;
|
|
396
|
+
const pos = get(await c.participant(item(p, 'id')), 'public_position', {});
|
|
397
|
+
out.push({handle: item(p, 'handle'), id: item(p, 'id'), name: item(p, 'display_name'), operator: item(p, 'operator'), about: item(p, 'about'),
|
|
398
|
+
services: item(p, 'services'), credit_notes_they_hold_PC: get(pos, 'claims_held'), credit_notes_they_owe_PC: get(pos, 'owes'),
|
|
399
|
+
deals: get(pos, 'deals'), agent_last_seen: get(p, 'agent_last_seen'),
|
|
400
|
+
agent_recently_active: recentlyActive(get(p, 'agent_last_seen'), 24, get(p, 'joined_at'), this.#now())});
|
|
401
|
+
}
|
|
402
|
+
out.sort((x, y) => Number(!x.agent_recently_active) - Number(!y.agent_recently_active)); // stable: active first
|
|
403
|
+
return {participants: out, note: 'positions are outstanding credit notes recorded by the pilot Basis tracker; nothing is settled; '
|
|
404
|
+
+ 'PC have no value. A participant whose agent has not been seen in the last 24 hours may never answer an offer.'};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async inbox() {
|
|
408
|
+
return this.client().inbox();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async deal(a) {
|
|
412
|
+
const c = this.client();
|
|
413
|
+
const d = await c.deal(item(a, 'deal_id'));
|
|
414
|
+
const out = Object.fromEntries(DEAL_FIELDS.map(k => [k, get(d, k)]));
|
|
415
|
+
if (truthy(get(d, 'pending_counter'))) {
|
|
416
|
+
const pc = item(d, 'pending_counter');
|
|
417
|
+
out.your_counter_offer_awaiting_owner = {version: item(pc, 'version'), state: item(pc, 'state'), terms: get(pc, 'terms'),
|
|
418
|
+
terms_hash: item(pc, 'terms_hash'), note: 'the other side cannot see it until your owner approves it'};
|
|
419
|
+
}
|
|
420
|
+
const cur = or(get(d, 'current'), {}), st = get(d, 'status');
|
|
421
|
+
if (st === 'open' && truthy(cur)) {
|
|
422
|
+
out.whose_turn = get(cur, 'by') !== c.participantId ? 'yours: accept, counter or decline'
|
|
423
|
+
: 'theirs: your offer is open; you cannot accept your own offer, only withdraw it or wait';
|
|
424
|
+
} else if (st === 'agreed') {
|
|
425
|
+
out.whose_turn = get(d, 'your_role') === 'payee' ? 'yours: deliver the work' : 'theirs: they deliver';
|
|
426
|
+
} else if (st === 'delivered' || st === 'note_failed') {
|
|
427
|
+
out.whose_turn = get(d, 'your_role') === 'payer' ? 'yours: accept the delivery or dispute it' : 'theirs: they review your delivery';
|
|
428
|
+
} else {
|
|
429
|
+
out.whose_turn = `nobody: the deal is ${str(st)}`;
|
|
430
|
+
}
|
|
431
|
+
const current = item(d, 'current');
|
|
432
|
+
out.current_terms = truthy(current) ? {version: item(current, 'version'), by: item(current, 'by'), state: item(current, 'state'),
|
|
433
|
+
terms: item(current, 'terms'), terms_hash: item(current, 'terms_hash')} : current;
|
|
434
|
+
if (get(d, 'status') === 'open' && truthy(get(d, 'current'))) {
|
|
435
|
+
try {
|
|
436
|
+
const chk = await c.verifyOffer(d);
|
|
437
|
+
out.checked_by_your_client = {terms_hash_recomputed: item(chk, 'terms_hash'), proposer_signature_valid: true};
|
|
438
|
+
} catch (e) {
|
|
439
|
+
if (!isValunError(e)) throw e;
|
|
440
|
+
out.checked_by_your_client = {problem: e.code, message: e.message, advice: 'do not accept these terms'};
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (truthy(get(d, 'delivery'))) {
|
|
444
|
+
const dl = item(d, 'delivery');
|
|
445
|
+
const content = item(dl, 'content');
|
|
446
|
+
const body = cpLength(content) <= 16000 ? content
|
|
447
|
+
: cpSlice(content, 16000) + '\n[... truncated for display; full text is on the server ...]';
|
|
448
|
+
out.delivery = {title: get(dl, 'title'), sha256: item(dl, 'sha256'), bytes: item(dl, 'bytes'), late: get(dl, 'late'), content: body};
|
|
449
|
+
const reviewing = get(d, 'your_role') === 'payer' && ['delivered', 'note_failed'].includes(get(d, 'status'));
|
|
450
|
+
out.delivery.measured_by_your_client = await measure(content, termsText(d), this.#measureOptions(reviewing && this.checkLinkHosts));
|
|
451
|
+
const exact = checkDelivery(or(get(or(get(d, 'current'), {}), 'terms'), {}), content);
|
|
452
|
+
if (exact !== null) {
|
|
453
|
+
out.delivery.checked_exactly_by_your_client = exact;
|
|
454
|
+
if (reviewing && truthy(exact.checked) && !truthy(exact.exact_result_matches)) {
|
|
455
|
+
out.delivery.advice = 'The delivered result does not match the exact result of the agreed text '
|
|
456
|
+
+ `(differs in: ${or(exact.differs_in, ['the result block']).join(', ')}). `
|
|
457
|
+
+ 'Dispute it with that reason instead of issuing a credit note.';
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (truthy(get(d, 'credit_note'))) {
|
|
462
|
+
const n = item(d, 'credit_note');
|
|
463
|
+
const tn = or(get(n, 'tracker_note'), {});
|
|
464
|
+
out.credit_note = {state: get(n, 'state'), amount_PC: get(n, 'amount'), tracker_status: get(n, 'tracker_status'),
|
|
465
|
+
tracker_cumulative_total_PC: get(tn, 'amount_collected'), confirmation: get(tn, 'confirmation'),
|
|
466
|
+
verified_by_valun: get(n, 'verified_by_valun'), outstanding: get(n, 'outstanding'), settled: get(n, 'settled', false),
|
|
467
|
+
issuer_pubkey: get(n, 'issuer_pubkey'), recipient_pubkey: get(n, 'recipient_pubkey')};
|
|
468
|
+
if (truthy(get(n, 'signature_withheld'))) out.credit_note.signature = 'withheld until the Basis tracker records the note';
|
|
469
|
+
}
|
|
470
|
+
out.history = get(d, 'history', []).slice(-12);
|
|
471
|
+
const them = get(or(get(d, 'counterparty'), {}), 'handle');
|
|
472
|
+
const prices = iterate(or(get(d, 'versions'), [])).map(v => {
|
|
473
|
+
const n = item(v, 'version'), who = item(v, 'by') === c.participantId ? 'you' : them;
|
|
474
|
+
return [n, who, item(item(v, 'price'), 'amount')];
|
|
475
|
+
});
|
|
476
|
+
if (prices.length) {
|
|
477
|
+
out.negotiation = {prices_by_version: prices.map(([n, who, p]) => `v${str(n)} ${str(who)}: ${str(p)} PC`)};
|
|
478
|
+
const mine = prices.filter(([, who]) => who === 'you').map(([, , p]) => p).slice(-2);
|
|
479
|
+
const theirs = prices.filter(([, who]) => who !== 'you').map(([, , p]) => p).slice(-2);
|
|
480
|
+
if (mine.length === 2 && theirs.length === 2 && pyEq(mine[0], mine[1]) && pyEq(theirs[0], theirs[1]) && !pyEq(mine[0], theirs[0])) {
|
|
481
|
+
out.negotiation.stalemate = `Both sides have repeated the same price (${str(mine[0])} vs ${str(theirs[0])} PC). If no price `
|
|
482
|
+
+ 'within your limits works for the other side, decline with a reason instead of countering again.';
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async propose(a) {
|
|
489
|
+
const c = this.client();
|
|
490
|
+
const other = await this.#resolve(c, item(a, 'counterparty'));
|
|
491
|
+
// A seller of exactly checkable work publishes that it needs the text in the description; check before signing.
|
|
492
|
+
if (get(a, 'role') === 'payer' && str(or(get(other, 'services'), '')).includes('``` block')
|
|
493
|
+
&& extractText(or(get(a, 'description'), '')) === null) {
|
|
494
|
+
throw new ValunError('missing_text',
|
|
495
|
+
`Not signed. ${str(item(other, 'handle'))} needs the text to process inside the description, and yours has none. Put the `
|
|
496
|
+
+ 'exact text in a ``` fenced block in the description (or as the only "quoted" string, or after TEXT: at the '
|
|
497
|
+
+ 'end), then make the offer again.');
|
|
498
|
+
}
|
|
499
|
+
const counterpartyId = item(other, 'id'), role = item(a, 'role'), title = item(a, 'title'), description = item(a, 'description');
|
|
500
|
+
const deliverable = item(a, 'deliverable'), price = int(item(a, 'price'));
|
|
501
|
+
const deliverByHours = int(or(get(a, 'deliver_within_hours'), 48)), offerTtlHours = int(or(get(a, 'offer_valid_hours'), 24));
|
|
502
|
+
const r = await c.propose({counterpartyId, role, title, description, deliverable, price, deliverByHours, offerTtlHours});
|
|
503
|
+
const waiting = item(r, 'status') === 'proposed_awaiting_owner';
|
|
504
|
+
const idle = recentlyActive(get(other, 'agent_last_seen'), 24, get(other, 'joined_at'), this.#now()) ? null
|
|
505
|
+
: `${str(item(other, 'handle'))}'s agent has not been seen in the last 24 hours; it may never answer. Consider an active seller too.`;
|
|
506
|
+
const handle = str(item(other, 'handle')), shownPrice = str(item(a, 'price'));
|
|
507
|
+
return {deal_id: item(r, 'id'), status: item(r, 'status'),
|
|
508
|
+
terms_hash: truthy(get(r, 'current')) ? item(item(r, 'current'), 'terms_hash') : null,
|
|
509
|
+
what_happened: waiting ? `Your signed offer is waiting for your owner's approval before ${handle} can see it.`
|
|
510
|
+
: `${handle} can now accept, decline or counter your signed offer.`,
|
|
511
|
+
if_agreed: item(a, 'role') === 'payer'
|
|
512
|
+
? `After you accept their delivery you issue them a credit note of ${shownPrice} PC (you will owe them; not settled).`
|
|
513
|
+
: `After they accept your delivery they issue you a credit note of ${shownPrice} PC (they will owe you; not settled).`,
|
|
514
|
+
...(idle ? {warning: idle} : {})};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async respond(a) {
|
|
518
|
+
const c = this.client();
|
|
519
|
+
const act = item(a, 'action'), did = item(a, 'deal_id');
|
|
520
|
+
let r;
|
|
521
|
+
if (act === 'accept') {
|
|
522
|
+
r = await c.accept(did, {expectedTermsHash: get(a, 'expected_terms_hash')});
|
|
523
|
+
} else if (act === 'decline') {
|
|
524
|
+
if (!truthy(strip(or(get(a, 'reason'), '')))) {
|
|
525
|
+
throw new ValunError('reason_required', 'a decline needs a short reason: the other side reads it and may make a better offer');
|
|
526
|
+
}
|
|
527
|
+
r = await c.decline(did, strip(item(a, 'reason')));
|
|
528
|
+
} else if (act === 'withdraw') {
|
|
529
|
+
r = await c.withdraw(did);
|
|
530
|
+
} else if (act === 'counter') {
|
|
531
|
+
r = await c.counter(did, {price: get(a, 'price'), title: get(a, 'title'), description: get(a, 'description'),
|
|
532
|
+
deliverable: get(a, 'deliverable'), deliverByHours: get(a, 'deliver_within_hours')});
|
|
533
|
+
} else {
|
|
534
|
+
throw new ValunError('bad_action', 'action must be accept, decline, counter or withdraw');
|
|
535
|
+
}
|
|
536
|
+
const held = act === 'counter' && truthy(get(r, 'pending_counter'));
|
|
537
|
+
const explain = {agreed: 'Agreement formed: both sides have signed the same terms.',
|
|
538
|
+
accepted_awaiting_owner: 'You signed the acceptance; it becomes binding when the owner holding it approves '
|
|
539
|
+
+ "(yours, or the proposer's if their limits require it; valun_deal shows which).",
|
|
540
|
+
open: held ? "Your signed counter-offer waits for your owner's approval; the other side cannot see it yet."
|
|
541
|
+
: act === 'counter' ? 'Your counter-offer is open for the other side.' : 'The offer is open.',
|
|
542
|
+
declined: 'You declined; nothing is owed.', withdrawn: 'Your offer is withdrawn.'};
|
|
543
|
+
const status = item(r, 'status');
|
|
544
|
+
return {deal_id: did, status, what_happened: typeof status === 'string' && Object.hasOwn(explain, status) ? explain[status] : status,
|
|
545
|
+
next_actions: get(r, 'next_actions')};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async deliver(a) {
|
|
549
|
+
const c = this.client();
|
|
550
|
+
const d = await c.deal(item(a, 'deal_id'));
|
|
551
|
+
const m = await measure(item(a, 'content'), termsText(d), this.#measureOptions(this.checkLinkHosts));
|
|
552
|
+
const problems = m.checks_against_agreed_terms.filter(x => !x.met).map(x => `${x.requirement}: measured ${x.measured}`);
|
|
553
|
+
if (truthy(m.warning ?? null)) problems.push(m.warning);
|
|
554
|
+
if (problems.length && !truthy(get(a, 'send_anyway'))) {
|
|
555
|
+
throw new ValunError('draft_does_not_meet_terms', 'Not sent. Your draft does not meet the agreed terms as measured: '
|
|
556
|
+
+ problems.join(' | ') + ' Fix it and deliver again (send_anyway=true only if you are sure; '
|
|
557
|
+
+ 'the payer sees the same measurements).', undefined, {measured: m});
|
|
558
|
+
}
|
|
559
|
+
const r = await c.deliver(item(a, 'deal_id'), item(a, 'content'), {title: or(get(a, 'title'), '')});
|
|
560
|
+
return {deal_id: item(r, 'id'), status: item(r, 'status'), sha256: item(item(r, 'delivery'), 'sha256'),
|
|
561
|
+
what_happened: 'Delivered and signed. The payer will accept it (issuing you a credit note) or dispute it.'};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async review(a) {
|
|
565
|
+
if (!['accept', 'dispute'].includes(get(a, 'decision'))) {
|
|
566
|
+
throw new ValunError('bad_decision', "decision must be exactly 'accept' or 'dispute'; nothing was signed");
|
|
567
|
+
}
|
|
568
|
+
const c = this.client();
|
|
569
|
+
if (item(a, 'decision') === 'dispute') {
|
|
570
|
+
if (!truthy(strip(or(get(a, 'reason'), '')))) throw new ValunError('reason_required', 'a dispute needs a reason');
|
|
571
|
+
const r = await c.dispute(item(a, 'deal_id'), item(a, 'reason'));
|
|
572
|
+
return {deal_id: item(r, 'id'), status: item(r, 'status'), what_happened: 'Disputed. No credit note was issued; the dispute is recorded.'};
|
|
573
|
+
}
|
|
574
|
+
const r = await c.complete(item(a, 'deal_id'), {expectedDeliverableSha256: get(a, 'expected_deliverable_sha256')});
|
|
575
|
+
const n = or(get(r, 'credit_note'), {});
|
|
576
|
+
const status = item(r, 'status');
|
|
577
|
+
let msg;
|
|
578
|
+
if (status === 'completion_awaiting_owner') {
|
|
579
|
+
msg = 'You accepted the delivery and signed the credit note; your owner must approve before it is issued to the tracker.';
|
|
580
|
+
} else if (status === 'note_issued') {
|
|
581
|
+
msg = `Credit note issued: the Basis tracker now records that you owe ${str(item(item(r, 'counterparty'), 'handle'))} `
|
|
582
|
+
+ `${str(get(or(get(n, 'tracker_note'), {}), 'amount_collected'))} PC in total (this deal added ${str(get(n, 'amount'))}). ` + SETTLEMENT_NOTE;
|
|
583
|
+
} else {
|
|
584
|
+
msg = status;
|
|
585
|
+
}
|
|
586
|
+
return {deal_id: item(r, 'id'), status, what_happened: msg,
|
|
587
|
+
credit_note: {state: get(n, 'state'), amount_PC: get(n, 'amount'), tracker_status: get(n, 'tracker_status'),
|
|
588
|
+
confirmation: get(or(get(n, 'tracker_note'), {}), 'confirmation'), settled: false}};
|
|
589
|
+
}
|
|
590
|
+
}
|