@jinn-network/task-posting 0.0.0 → 0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b
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/README.md +96 -1
- package/dist/digest.d.ts +12 -0
- package/dist/digest.js +17 -0
- package/dist/dispatch-submission.d.ts +24 -0
- package/dist/dispatch-submission.js +93 -0
- package/dist/execute.d.ts +54 -0
- package/dist/execute.js +134 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +11 -0
- package/dist/order.d.ts +6 -0
- package/dist/order.js +13 -0
- package/dist/plan.d.ts +8 -0
- package/dist/plan.js +105 -0
- package/dist/pool-entry.d.ts +34 -0
- package/dist/pool-entry.js +90 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.js +2 -0
- package/package.json +45 -5
package/README.md
CHANGED
|
@@ -1 +1,96 @@
|
|
|
1
|
-
|
|
1
|
+
# @jinn-network/task-posting
|
|
2
|
+
|
|
3
|
+
> Candidate in `implementations-v1`. Eligible for receipt-gated canary; that is not independent ratification.
|
|
4
|
+
|
|
5
|
+
Supply policy for marketplace posting: which pool entries post, when, at what terms, under whose
|
|
6
|
+
identity, with the escrow surfaced before spending.
|
|
7
|
+
|
|
8
|
+
`planPosting(pool, policy)` is pure — a materialized pool listing plus a policy in, a
|
|
9
|
+
`PostingPlan` out, carrying per-entry and total escrow. `executePosting(deps, plan)` surfaces that
|
|
10
|
+
plan, requires approval before spending, and posts through an injected requester backend.
|
|
11
|
+
Explicit post is the default; auto-post is an opt-in standing policy flag with the same visibility
|
|
12
|
+
in a log line.
|
|
13
|
+
|
|
14
|
+
The plan is the replay unit: the sealed dispatch Submission is a function of the plan alone, so
|
|
15
|
+
re-executing the same plan produces byte-identical Submission bytes, the same broadcast-intent
|
|
16
|
+
key, and a replayed outcome rather than a second post.
|
|
17
|
+
|
|
18
|
+
## Requester posting boundary
|
|
19
|
+
|
|
20
|
+
This package owns supply **policy** only. `PostingDeps.backend` is the existing
|
|
21
|
+
`MarketplaceRequesterBackend` from `@jinn-network/marketplace-binding`. It owns exact Task and
|
|
22
|
+
Submission admission, requester/idempotency scope, the posting WAL, recovered outcome
|
|
23
|
+
reconciliation and the actual transaction. This package never receives raw chain configuration,
|
|
24
|
+
wallet/broadcast ports, or a direct `postTask` function. The host must compose the backend with the
|
|
25
|
+
same creator and terms used to construct the plan; those configured venue inputs do not cross the
|
|
26
|
+
per-post operation boundary.
|
|
27
|
+
|
|
28
|
+
`executePosting` calls `backend.post(taskBytes, submissionBytes)` only after the plan is surfaced,
|
|
29
|
+
approval is granted and the complete batch passes its pure preflight. A recovered backend outcome
|
|
30
|
+
is accepted exactly like the original result. A retryable unresolved operation is reported as
|
|
31
|
+
uncertain and is never retried here; the host may invoke `backend.recoverPosting()` before a later
|
|
32
|
+
policy run. No separate work-client package or wrapper is required.
|
|
33
|
+
|
|
34
|
+
## Reported to the program: F-C5-8 (resolved — ruling R10)
|
|
35
|
+
|
|
36
|
+
**The pool listing this package is pinned to consume is not `PostingPoolEntry`, and one field of
|
|
37
|
+
`PostingPoolEntry` has no producer anywhere in the stack.** The C5 plan's Task B5 pins
|
|
38
|
+
`(entry: PoolListing[number]) => PostingPoolEntry` and instructs a stop-and-report if it does not
|
|
39
|
+
compile (program §5 contract 11). It does not compile. This is that report.
|
|
40
|
+
|
|
41
|
+
The task-derivation `SupplyPool.list()` yields `PoolEntrySummary`:
|
|
42
|
+
|
|
43
|
+
| `PostingPoolEntry` needs | C4 has |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| `taskBytes` | on `PoolEntry` via `SupplyPool.get()`, not on the listing element |
|
|
46
|
+
| `admissionReceiptDigest` | the same value, named `receiptDigest` |
|
|
47
|
+
| `evaluationSpecPublic` | not modelled — no package in the stack computes it |
|
|
48
|
+
|
|
49
|
+
The plan's own pre-flight gate accepts the first row (it stops only when the pool exposes neither
|
|
50
|
+
the bytes "or a way to read them" nor a per-entry receipt digest). The other two are unreconciled
|
|
51
|
+
between C4's pool contract and spec §8's posting inputs.
|
|
52
|
+
|
|
53
|
+
`evaluationSpecPublic` is the load-bearing one: it is the sole gate implementing D5 ("v1 posts
|
|
54
|
+
public-specification evaluation legs only") in both `planPosting` (the `evaluation-not-public`
|
|
55
|
+
skip) and `buildDispatchSubmission` (`assertPublicSpecEvaluationLeg`). A boolean nobody produces
|
|
56
|
+
makes every D5 refusal a caller's claim rather than a fact about the sealed bytes.
|
|
57
|
+
|
|
58
|
+
**What this package does.** `PostingPoolEntry` is untouched — it is exactly what the
|
|
59
|
+
plan pins, and it was not widened. The reconciliation lives in one named, tested adapter,
|
|
60
|
+
`postingPoolEntry(entry, options)`, which carries C4's `receiptDigest` into
|
|
61
|
+
`admissionReceiptDigest` and **derives** `evaluationSpecPublic` from the sealed EvaluationSpec
|
|
62
|
+
bytes the entry's own digest addresses: D5's stamping writes `accessClass: "public"` onto every
|
|
63
|
+
access-classified descriptor at seal time, and `evaluationSpecIsPublic` reads those stamps back,
|
|
64
|
+
failing closed on an unstamped specification, a private stamp, a non-string stamp, or bytes that
|
|
65
|
+
are not a JSON document. `src/pool-shape.test.ts` is the package's sole, test-only dependency on
|
|
66
|
+
task derivation; it pins C4's stored entry against the adapter input type at compile time, so a
|
|
67
|
+
rename on either side stops the build.
|
|
68
|
+
|
|
69
|
+
**How the program ruled (R10).** The C5 plan was wrong on every count; C4 is unchanged.
|
|
70
|
+
|
|
71
|
+
1. **`receiptDigest` stays C4's name** — it is what the program pinned (§4, ruling R5). Posting
|
|
72
|
+
renames at its own boundary. The plan's alternative, renaming C4 to match the plan's guess,
|
|
73
|
+
would have churned a pinned interface to spare this package one adapter.
|
|
74
|
+
2. **`evaluationSpecPublic` is derived here, permanently** — never modelled on the pool entry and
|
|
75
|
+
never carried. Deriving it from the sealed bytes is what makes a D5 refusal a fact about the
|
|
76
|
+
specification rather than a claim by whoever produced the entry; a carried boolean would be a
|
|
77
|
+
silent downgrade of the gate. `src/pool-shape.test.ts` pin (d) fails the build if any producer
|
|
78
|
+
grows such a field, and the fix in that case is to keep deriving, not to start trusting it.
|
|
79
|
+
3. **The adapter stays on the public surface.** `postingPoolEntry`, `evaluationSpecIsPublic`, and
|
|
80
|
+
`SuppliedPoolEntry` are exported: the tier-4 composition is the only place holding both a pool
|
|
81
|
+
and this application, so it is the caller that needs them, and exporting the tested adapter is
|
|
82
|
+
what stops each composition from inventing a looser one. The program's §4 "C5 produces" list is
|
|
83
|
+
amended accordingly.
|
|
84
|
+
|
|
85
|
+
The plan's Task B5 identity assertion is likewise amended: it pins the two-step join
|
|
86
|
+
(`SupplyPool.get()` → `SuppliedPoolEntry` → `PostingPoolEntry`) rather than the listing element,
|
|
87
|
+
because a listing that carried every pair's sealed bytes would be the wrong contract for a pool
|
|
88
|
+
that exists to be enumerated cheaply.
|
|
89
|
+
|
|
90
|
+
## What v1 does not do
|
|
91
|
+
|
|
92
|
+
- No pricing engine — terms are operator-supplied (design §12).
|
|
93
|
+
- No derivation, no admission, no attestation reading. Posting carries the admission receipt by
|
|
94
|
+
digest and never re-decides admission.
|
|
95
|
+
- No private evaluation material: v1 posts public-specification evaluation legs only, and
|
|
96
|
+
`capabilityGrants` is never populated (design §8, D5).
|
package/dist/digest.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one shape a record-body digest may take in this package: `sha256:` plus 64 lower-case hex
|
|
3
|
+
* characters (program §5 contract 6). Bare hex is the in-toto DigestSet form and the on-chain
|
|
4
|
+
* `bytes32` anchor form; the two are not interchangeable, and every conversion between them is
|
|
5
|
+
* explicit and checked at the point of conversion.
|
|
6
|
+
*/
|
|
7
|
+
export declare const PREFIXED_SHA256_PATTERN: RegExp;
|
|
8
|
+
/**
|
|
9
|
+
* Fails closed on any digest that is not in the canonical prefixed form. Callers pass the field
|
|
10
|
+
* name so the refusal names the input the operator has to fix, not just its value.
|
|
11
|
+
*/
|
|
12
|
+
export declare function assertPrefixedSha256(digest: string, field: string): asserts digest is `sha256:${string}`;
|
package/dist/digest.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
/**
|
|
3
|
+
* The one shape a record-body digest may take in this package: `sha256:` plus 64 lower-case hex
|
|
4
|
+
* characters (program §5 contract 6). Bare hex is the in-toto DigestSet form and the on-chain
|
|
5
|
+
* `bytes32` anchor form; the two are not interchangeable, and every conversion between them is
|
|
6
|
+
* explicit and checked at the point of conversion.
|
|
7
|
+
*/
|
|
8
|
+
export const PREFIXED_SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
|
9
|
+
/**
|
|
10
|
+
* Fails closed on any digest that is not in the canonical prefixed form. Callers pass the field
|
|
11
|
+
* name so the refusal names the input the operator has to fix, not just its value.
|
|
12
|
+
*/
|
|
13
|
+
export function assertPrefixedSha256(digest, field) {
|
|
14
|
+
if (!PREFIXED_SHA256_PATTERN.test(digest)) {
|
|
15
|
+
throw new Error(`${field} ${JSON.stringify(digest)} is not a sha256:-prefixed lower-case 64-hex digest`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { PostingPlan, PostingPlanEntry, PostingPoolEntry } from "./types.js";
|
|
2
|
+
/** Namespace for the identifiers derived below; part of the byte-stability contract. */
|
|
3
|
+
export declare const POSTING_SUBMISSION_NAMESPACE: "jinn:task-posting:submission:v1";
|
|
4
|
+
/**
|
|
5
|
+
* v1 posts public-specification evaluation legs only (design §8, D5). Grant hosting, minting, and
|
|
6
|
+
* redemption are non-goals (§12), so this package has no code path that populates
|
|
7
|
+
* `capabilityGrants` -- and refuses the entry that would need one rather than posting it with the
|
|
8
|
+
* private material silently dropped.
|
|
9
|
+
*/
|
|
10
|
+
export declare function assertPublicSpecEvaluationLeg(entry: PostingPoolEntry): void;
|
|
11
|
+
/**
|
|
12
|
+
* Reads `attempts.maxTotal` back out of the bytes that were just sealed and holds it against the
|
|
13
|
+
* terms the escrow was computed from (finding F-C5-1's disposition: "half B seals
|
|
14
|
+
* `attempts.maxTotal` explicitly **and re-parses its own sealed bytes to assert agreement**").
|
|
15
|
+
*
|
|
16
|
+
* Comparing the plan's two in-memory numbers is a tautology. The gate that matters is over the
|
|
17
|
+
* bytes, because the requester backend takes the escrow multiplier from the sealed document
|
|
18
|
+
* (`submission.attempts?.maxTotal ?? 1`): a sealing layer that dropped, renamed, or coerced the
|
|
19
|
+
* field would send `(solution + verdict) x 1` to the chain while the surfaced plan and its log
|
|
20
|
+
* line said N. `assertMaxClaimsAgreement` throws on `undefined` precisely to make that fallback
|
|
21
|
+
* unreachable from here.
|
|
22
|
+
*/
|
|
23
|
+
export declare function assertSealedMaxClaimsAgreement(submissionBytes: Uint8Array, termsMaxClaims: number): void;
|
|
24
|
+
export declare function buildDispatchSubmission(entry: PostingPoolEntry, planEntry: PostingPlanEntry, plan: PostingPlan): Uint8Array;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// The dispatch Submission a planned entry posts under. Every field is a function of the plan and
|
|
3
|
+
// the entry -- no clock read, no random identifier -- because the broadcast-intent WAL is keyed on
|
|
4
|
+
// `(creatorSafe, taskCidDigest, submissionDigest)`: a Submission that differed between two runs of
|
|
5
|
+
// one plan would key a second intent and pay for a second post.
|
|
6
|
+
import { ADMISSION_RECEIPT_ANNOTATION_URI, assertMaxClaimsAgreement, } from "@jinn-network/marketplace-binding";
|
|
7
|
+
import { SubmissionRecordSchema, TASK_EXECUTION_PROTOCOL_URI, sealSubmission, sha256Hex, } from "@jinn-network/task-execution-protocol";
|
|
8
|
+
import { PREFIXED_SHA256_PATTERN } from "./digest.js";
|
|
9
|
+
/** Namespace for the identifiers derived below; part of the byte-stability contract. */
|
|
10
|
+
export const POSTING_SUBMISSION_NAMESPACE = "jinn:task-posting:submission:v1";
|
|
11
|
+
/**
|
|
12
|
+
* Record-body digests are `sha256:`-prefixed; in-toto DigestSet values are bare hex (program §5
|
|
13
|
+
* contract 6). This is the only conversion in this package that lands inside a document a
|
|
14
|
+
* requester pays to post, and nothing downstream re-checks it: TEP's `DigestMap` requires only
|
|
15
|
+
* non-empty keys, and the evaluation leg checks only that the admission-receipt descriptor is
|
|
16
|
+
* present and named. An unchecked `.slice()` here would seal `""`, `sha256:aaa...`, `AAA...`, or
|
|
17
|
+
* `zz` into an escrowed task and every later gate would still pass.
|
|
18
|
+
*/
|
|
19
|
+
function bareHex(digest, field) {
|
|
20
|
+
if (!PREFIXED_SHA256_PATTERN.test(digest)) {
|
|
21
|
+
throw new Error(`${field} ${JSON.stringify(digest)} is not a sha256:-prefixed lower-case 64-hex digest `
|
|
22
|
+
+ "-- refusing to seal it into a Submission that is about to be posted and escrowed");
|
|
23
|
+
}
|
|
24
|
+
return digest.slice("sha256:".length);
|
|
25
|
+
}
|
|
26
|
+
/** 32 hex characters of a digest, shaped as a URN UUID (§8's `submission` identifier form). */
|
|
27
|
+
function urnUuidFromHex(hex) {
|
|
28
|
+
return `urn:uuid:${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`
|
|
29
|
+
+ `-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* v1 posts public-specification evaluation legs only (design §8, D5). Grant hosting, minting, and
|
|
33
|
+
* redemption are non-goals (§12), so this package has no code path that populates
|
|
34
|
+
* `capabilityGrants` -- and refuses the entry that would need one rather than posting it with the
|
|
35
|
+
* private material silently dropped.
|
|
36
|
+
*/
|
|
37
|
+
export function assertPublicSpecEvaluationLeg(entry) {
|
|
38
|
+
if (entry.evaluationSpecPublic !== true) {
|
|
39
|
+
throw new Error(`${entry.taskDigest} declares a non-public evaluation specification; v1 posts `
|
|
40
|
+
+ "public-specification evaluation legs only (design §8, D5)");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Reads `attempts.maxTotal` back out of the bytes that were just sealed and holds it against the
|
|
45
|
+
* terms the escrow was computed from (finding F-C5-1's disposition: "half B seals
|
|
46
|
+
* `attempts.maxTotal` explicitly **and re-parses its own sealed bytes to assert agreement**").
|
|
47
|
+
*
|
|
48
|
+
* Comparing the plan's two in-memory numbers is a tautology. The gate that matters is over the
|
|
49
|
+
* bytes, because the requester backend takes the escrow multiplier from the sealed document
|
|
50
|
+
* (`submission.attempts?.maxTotal ?? 1`): a sealing layer that dropped, renamed, or coerced the
|
|
51
|
+
* field would send `(solution + verdict) x 1` to the chain while the surfaced plan and its log
|
|
52
|
+
* line said N. `assertMaxClaimsAgreement` throws on `undefined` precisely to make that fallback
|
|
53
|
+
* unreachable from here.
|
|
54
|
+
*/
|
|
55
|
+
export function assertSealedMaxClaimsAgreement(submissionBytes, termsMaxClaims) {
|
|
56
|
+
const parsed = SubmissionRecordSchema.parse(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(submissionBytes)));
|
|
57
|
+
assertMaxClaimsAgreement(parsed.attempts?.maxTotal, termsMaxClaims);
|
|
58
|
+
}
|
|
59
|
+
export function buildDispatchSubmission(entry, planEntry, plan) {
|
|
60
|
+
assertPublicSpecEvaluationLeg(entry);
|
|
61
|
+
const receiptHex = bareHex(entry.admissionReceiptDigest, "admissionReceiptDigest");
|
|
62
|
+
const taskHex = sha256Hex(entry.taskBytes);
|
|
63
|
+
if (taskHex !== bareHex(entry.taskDigest, "taskDigest")) {
|
|
64
|
+
throw new Error(`pool entry ${entry.taskDigest} does not hash to its own bytes (sha256:${taskHex}) `
|
|
65
|
+
+ "-- refusing to seal a Submission against a mismatched pair");
|
|
66
|
+
}
|
|
67
|
+
if (planEntry.maxClaims !== plan.terms.maxClaims) {
|
|
68
|
+
throw new Error(`plan entry maxClaims (${planEntry.maxClaims}) disagrees with the plan's terms `
|
|
69
|
+
+ `(${plan.terms.maxClaims})`);
|
|
70
|
+
}
|
|
71
|
+
const seed = sha256Hex(new TextEncoder().encode(`${POSTING_SUBMISSION_NAMESPACE}|${entry.taskDigest}|${plan.requester}|${planEntry.deadline}`
|
|
72
|
+
+ `|${planEntry.closeAt ?? ""}|${planEntry.maxClaims}`));
|
|
73
|
+
const submissionBytes = sealSubmission({
|
|
74
|
+
protocol: TASK_EXECUTION_PROTOCOL_URI,
|
|
75
|
+
submission: urnUuidFromHex(seed),
|
|
76
|
+
task: { digest: { sha256: taskHex } },
|
|
77
|
+
requester: plan.requester,
|
|
78
|
+
idempotencyKey: `${POSTING_SUBMISSION_NAMESPACE}:${entry.taskDigest}:${planEntry.deadline}`,
|
|
79
|
+
nonce: seed.slice(0, 32),
|
|
80
|
+
deadline: planEntry.deadline,
|
|
81
|
+
...(planEntry.closeAt === undefined ? {} : { closeAt: planEntry.closeAt }),
|
|
82
|
+
attempts: { maxTotal: planEntry.maxClaims },
|
|
83
|
+
annotations: {
|
|
84
|
+
[ADMISSION_RECEIPT_ANNOTATION_URI]: {
|
|
85
|
+
name: "admission-receipt",
|
|
86
|
+
digest: { sha256: receiptHex },
|
|
87
|
+
...(entry.admissionReceiptUri === undefined ? {} : { uri: entry.admissionReceiptUri }),
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
assertSealedMaxClaimsAgreement(submissionBytes, plan.terms.maxClaims);
|
|
92
|
+
return submissionBytes;
|
|
93
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type MarketplaceRequesterBackend } from "@jinn-network/marketplace-binding";
|
|
2
|
+
import type { PostingPlan, PostingPoolEntry } from "./types.js";
|
|
3
|
+
/** Shows the operator what is about to be spent. Rendering is the host's job, not this package's. */
|
|
4
|
+
export interface PostingRenderPort {
|
|
5
|
+
renderPlan(plan: PostingPlan): void | Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export type PostingApproval = {
|
|
8
|
+
readonly approved: true;
|
|
9
|
+
} | {
|
|
10
|
+
readonly approved: false;
|
|
11
|
+
readonly reason: string;
|
|
12
|
+
};
|
|
13
|
+
export interface PostingApprovalPort {
|
|
14
|
+
approvePlan(plan: PostingPlan): Promise<PostingApproval>;
|
|
15
|
+
}
|
|
16
|
+
export interface PostingLogLine {
|
|
17
|
+
readonly event: "posting.plan-surfaced" | "posting.auto-approved" | "posting.approved" | "posting.refused" | "posting.posted" | "posting.uncertain";
|
|
18
|
+
readonly fields: Readonly<Record<string, string>>;
|
|
19
|
+
}
|
|
20
|
+
/** Structured sink; this package never writes to an ambient console. */
|
|
21
|
+
export interface PostingLogPort {
|
|
22
|
+
record(line: PostingLogLine): void;
|
|
23
|
+
}
|
|
24
|
+
export interface PostingDeps {
|
|
25
|
+
/** Task digest -> pool entry. The plan carries digests; the bytes are read here. */
|
|
26
|
+
readonly entries: ReadonlyMap<string, PostingPoolEntry>;
|
|
27
|
+
/**
|
|
28
|
+
* Sole authority for requester scope, posting WAL and recovered outcome reconciliation.
|
|
29
|
+
* The host composes it with the same creator and terms used to construct the plan.
|
|
30
|
+
*/
|
|
31
|
+
readonly backend: MarketplaceRequesterBackend;
|
|
32
|
+
readonly render: PostingRenderPort;
|
|
33
|
+
readonly approval: PostingApprovalPort;
|
|
34
|
+
readonly log: PostingLogPort;
|
|
35
|
+
}
|
|
36
|
+
export interface PostingRunSummary {
|
|
37
|
+
readonly posted: readonly {
|
|
38
|
+
readonly taskDigest: `sha256:${string}`;
|
|
39
|
+
readonly taskId: bigint;
|
|
40
|
+
readonly txHash: `0x${string}`;
|
|
41
|
+
}[];
|
|
42
|
+
readonly uncertain: readonly {
|
|
43
|
+
readonly taskDigest: `sha256:${string}`;
|
|
44
|
+
readonly detail: string;
|
|
45
|
+
}[];
|
|
46
|
+
/** Set only when approval was withheld; the batch then spent nothing. */
|
|
47
|
+
readonly refused?: string;
|
|
48
|
+
readonly spentEscrowValueWei: bigint;
|
|
49
|
+
}
|
|
50
|
+
export declare class PostingRefusedError extends Error {
|
|
51
|
+
readonly reason: string;
|
|
52
|
+
constructor(reason: string, message: string);
|
|
53
|
+
}
|
|
54
|
+
export declare function executePosting(deps: PostingDeps, plan: PostingPlan): Promise<PostingRunSummary>;
|
package/dist/execute.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Explicit post is the default (design §8): the plan -- terms and the computed escrow total -- is
|
|
3
|
+
// surfaced before a wei is spent, and an approval decision is required. Auto-post is an opt-in
|
|
4
|
+
// standing policy flag that pre-approves with THE SAME fields in a log line, so a standing policy
|
|
5
|
+
// is never quieter than a hand-approved one (visible-money-actions).
|
|
6
|
+
//
|
|
7
|
+
// Everything that touches the chain is injected. The requester backend is the sole posting and
|
|
8
|
+
// recovery authority; this package retains only selection, terms, approval, batching and logging.
|
|
9
|
+
import { BroadcastUncertainError, postingEscrowValueWei, } from "@jinn-network/marketplace-binding";
|
|
10
|
+
import { buildDispatchSubmission } from "./dispatch-submission.js";
|
|
11
|
+
export class PostingRefusedError extends Error {
|
|
12
|
+
reason;
|
|
13
|
+
constructor(reason, message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.reason = reason;
|
|
16
|
+
this.name = "PostingRefusedError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** The money fields, identical for the explicit and the auto path. */
|
|
20
|
+
function planFields(plan) {
|
|
21
|
+
return {
|
|
22
|
+
createdAt: plan.createdAt,
|
|
23
|
+
creatorSafe: plan.creatorSafe,
|
|
24
|
+
requester: plan.requester,
|
|
25
|
+
entries: String(plan.entries.length),
|
|
26
|
+
totalEscrowValueWei: String(plan.totalEscrowValueWei),
|
|
27
|
+
solutionMaxDeliveryRateWei: String(plan.terms.solutionMaxDeliveryRateWei),
|
|
28
|
+
verdictMaxDeliveryRateWei: String(plan.terms.verdictMaxDeliveryRateWei),
|
|
29
|
+
responseTimeoutSeconds: String(plan.terms.responseTimeoutSeconds),
|
|
30
|
+
maxClaims: String(plan.terms.maxClaims),
|
|
31
|
+
allowSolverSelfEvaluation: String(plan.terms.allowSolverSelfEvaluation),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Everything checkable about the batch, checked before the first wei leaves: the bytes for every
|
|
36
|
+
* planned digest are in hand, every entry's escrow is the escrow the plan's own terms imply, and
|
|
37
|
+
* every dispatch Submission seals. All three are pure, so a refusal raised from inside the post
|
|
38
|
+
* loop would only mean earlier entries were already posted and escrowed when it fired.
|
|
39
|
+
*
|
|
40
|
+
* The escrow re-derivation is the same class of check as the `maxClaims` gate one layer down:
|
|
41
|
+
* the requester backend computes the real `msg.value` from its configured terms and the sealed
|
|
42
|
+
* `attempts.maxTotal`. A plan carrying an understated `escrowValueWei` would render, log, and
|
|
43
|
+
* total a number that is not what the chain is sent.
|
|
44
|
+
*/
|
|
45
|
+
function prepareBatch(deps, plan) {
|
|
46
|
+
const expectedEscrowValueWei = postingEscrowValueWei(plan.terms);
|
|
47
|
+
return plan.entries.map((planEntry) => {
|
|
48
|
+
const entry = deps.entries.get(planEntry.taskDigest);
|
|
49
|
+
if (entry === undefined) {
|
|
50
|
+
throw new PostingRefusedError("pool-entry-missing", `planned entry ${planEntry.taskDigest} is not in the supplied pool -- refusing to post a `
|
|
51
|
+
+ "batch whose bytes cannot be read");
|
|
52
|
+
}
|
|
53
|
+
if (planEntry.escrowValueWei !== expectedEscrowValueWei) {
|
|
54
|
+
throw new PostingRefusedError("escrow-disagrees-with-terms", `planned entry ${planEntry.taskDigest} carries escrow ${planEntry.escrowValueWei} wei, but `
|
|
55
|
+
+ `this plan's terms imply ${expectedEscrowValueWei} wei -- the surfaced total would not `
|
|
56
|
+
+ "be what is sent");
|
|
57
|
+
}
|
|
58
|
+
return { planEntry, entry, submissionBytes: buildDispatchSubmission(entry, planEntry, plan) };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function uncertainOperationDetail(error) {
|
|
62
|
+
if (error instanceof BroadcastUncertainError)
|
|
63
|
+
return error.message;
|
|
64
|
+
// MarketplaceRequesterBackend exposes operational failures through TaskExecutionBackend's
|
|
65
|
+
// error contract. Avoid a second runtime package edge just to recognize the generic shape.
|
|
66
|
+
if (error instanceof Error
|
|
67
|
+
&& error.name === "TaskExecutionError"
|
|
68
|
+
&& "category" in error
|
|
69
|
+
&& error.category === "backend-unavailable"
|
|
70
|
+
&& "retryable" in error
|
|
71
|
+
&& error.retryable === true) {
|
|
72
|
+
const detail = "detail" in error ? error.detail : undefined;
|
|
73
|
+
return typeof detail === "string" ? detail : error.message;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
export async function executePosting(deps, plan) {
|
|
78
|
+
await deps.render.renderPlan(plan);
|
|
79
|
+
deps.log.record({ event: "posting.plan-surfaced", fields: planFields(plan) });
|
|
80
|
+
if (plan.approval === "auto") {
|
|
81
|
+
deps.log.record({ event: "posting.auto-approved", fields: planFields(plan) });
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
const decision = await deps.approval.approvePlan(plan);
|
|
85
|
+
if (!decision.approved) {
|
|
86
|
+
deps.log.record({
|
|
87
|
+
event: "posting.refused",
|
|
88
|
+
fields: { ...planFields(plan), reason: decision.reason },
|
|
89
|
+
});
|
|
90
|
+
return { posted: [], uncertain: [], refused: decision.reason, spentEscrowValueWei: 0n };
|
|
91
|
+
}
|
|
92
|
+
deps.log.record({ event: "posting.approved", fields: planFields(plan) });
|
|
93
|
+
}
|
|
94
|
+
// After the approval gate (a withheld plan is never inspected further) and before the first
|
|
95
|
+
// post: the whole batch is resolved and sealed, so a refusal cannot land mid-spend.
|
|
96
|
+
const prepared = prepareBatch(deps, plan);
|
|
97
|
+
const posted = [];
|
|
98
|
+
const uncertain = [];
|
|
99
|
+
let spentEscrowValueWei = 0n;
|
|
100
|
+
for (const { planEntry, entry, submissionBytes } of prepared) {
|
|
101
|
+
let outcome;
|
|
102
|
+
try {
|
|
103
|
+
// eslint-disable-next-line no-await-in-loop -- posts are sequential: one requester, one
|
|
104
|
+
// nonce sequence, and one escrow balance draining as the batch proceeds.
|
|
105
|
+
outcome = await deps.backend.post(entry.taskBytes, submissionBytes);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
const detail = uncertainOperationDetail(error);
|
|
109
|
+
if (detail !== undefined) {
|
|
110
|
+
// Never retried here: the requester backend resolves uncertain work through
|
|
111
|
+
// `recoverPosting()` and canonical evidence, not by posting again.
|
|
112
|
+
uncertain.push({ taskDigest: planEntry.taskDigest, detail });
|
|
113
|
+
deps.log.record({
|
|
114
|
+
event: "posting.uncertain",
|
|
115
|
+
fields: { taskDigest: planEntry.taskDigest, detail },
|
|
116
|
+
});
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
posted.push({ taskDigest: planEntry.taskDigest, taskId: outcome.taskId, txHash: outcome.txHash });
|
|
122
|
+
spentEscrowValueWei += planEntry.escrowValueWei;
|
|
123
|
+
deps.log.record({
|
|
124
|
+
event: "posting.posted",
|
|
125
|
+
fields: {
|
|
126
|
+
taskDigest: planEntry.taskDigest,
|
|
127
|
+
taskId: String(outcome.taskId),
|
|
128
|
+
txHash: outcome.txHash,
|
|
129
|
+
escrowValueWei: String(planEntry.escrowValueWei),
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return { posted, uncertain, spentEscrowValueWei };
|
|
134
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { planPosting } from "./plan.js";
|
|
2
|
+
export type { PostingPlan, PostingPlanEntry, PostingPolicy, PostingPoolEntry, PostingSkip, PostingSkipReason, } from "./types.js";
|
|
3
|
+
export { POSTING_SUBMISSION_NAMESPACE, buildDispatchSubmission } from "./dispatch-submission.js";
|
|
4
|
+
export { evaluationSpecIsPublic, postingPoolEntry } from "./pool-entry.js";
|
|
5
|
+
export type { PostingPoolEntryOptions, SuppliedPoolEntry } from "./pool-entry.js";
|
|
6
|
+
export { PostingRefusedError, executePosting } from "./execute.js";
|
|
7
|
+
export type { PostingApproval, PostingApprovalPort, PostingDeps, PostingLogLine, PostingLogPort, PostingRenderPort, PostingRunSummary, } from "./execute.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @jinn-network/task-posting -- public surface.
|
|
2
|
+
//
|
|
3
|
+
// Policy in, posts out. `PostingDeps.backend` is the sole requester posting/recovery authority;
|
|
4
|
+
// this package does not accept raw chain or binding ports.
|
|
5
|
+
export { planPosting } from "./plan.js";
|
|
6
|
+
export { POSTING_SUBMISSION_NAMESPACE, buildDispatchSubmission } from "./dispatch-submission.js";
|
|
7
|
+
// The pool-shape reconciliation the program still has to rule on (README, F-C5-8): one named
|
|
8
|
+
// adapter from what the supply pool stores to what this application posts, with D5's publicness
|
|
9
|
+
// read off the sealed specification bytes instead of taken as a caller's claim.
|
|
10
|
+
export { evaluationSpecIsPublic, postingPoolEntry } from "./pool-entry.js";
|
|
11
|
+
export { PostingRefusedError, executePosting } from "./execute.js";
|
package/dist/order.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UTF-16 code-unit ordering. `String.prototype.localeCompare` reads the host locale and the
|
|
3
|
+
* bundled ICU data, so it must never decide a batch's order: two hosts would plan two different
|
|
4
|
+
* batches from the same pool.
|
|
5
|
+
*/
|
|
6
|
+
export declare function compareCodeUnitStrings(left: string, right: string): number;
|
package/dist/order.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
/**
|
|
3
|
+
* UTF-16 code-unit ordering. `String.prototype.localeCompare` reads the host locale and the
|
|
4
|
+
* bundled ICU data, so it must never decide a batch's order: two hosts would plan two different
|
|
5
|
+
* batches from the same pool.
|
|
6
|
+
*/
|
|
7
|
+
export function compareCodeUnitStrings(left, right) {
|
|
8
|
+
if (left < right)
|
|
9
|
+
return -1;
|
|
10
|
+
if (left > right)
|
|
11
|
+
return 1;
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
package/dist/plan.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PostingPlan, PostingPolicy, PostingPoolEntry } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Selects and orders the entries this batch posts.
|
|
4
|
+
*
|
|
5
|
+
* @param pool the materialized pool listing (finding F-C5-2: the caller reads `SupplyPool.list()`
|
|
6
|
+
* and passes the result; a pure function cannot await a store).
|
|
7
|
+
*/
|
|
8
|
+
export declare function planPosting(pool: readonly PostingPoolEntry[], policy: PostingPolicy): PostingPlan;
|
package/dist/plan.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Supply policy, and nothing else (design §8, D6: production never posts; posting never derives).
|
|
3
|
+
// Pure by construction: the plan is the replay unit, so every value the sealed Submission depends
|
|
4
|
+
// on -- deadline, closeAt, maxClaims, requester -- is decided here and carried, never read from a
|
|
5
|
+
// host clock at execution time.
|
|
6
|
+
import { postingEscrowValueWei } from "@jinn-network/marketplace-binding";
|
|
7
|
+
import { PREFIXED_SHA256_PATTERN } from "./digest.js";
|
|
8
|
+
import { compareCodeUnitStrings } from "./order.js";
|
|
9
|
+
function offsetIso(base, seconds) {
|
|
10
|
+
const parsed = Date.parse(base);
|
|
11
|
+
if (Number.isNaN(parsed)) {
|
|
12
|
+
throw new Error(`policy.now must be an RFC 3339 timestamp with an offset, got ${base}`);
|
|
13
|
+
}
|
|
14
|
+
return new Date(parsed + seconds * 1_000).toISOString();
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Both policy digest lists are membership-tested by exact string equality against `sha256:`-
|
|
18
|
+
* prefixed entry digests, so a list written in any other form matches nothing and the guard it was
|
|
19
|
+
* written for silently disarms: an excluded entry posts and its escrow is spent, and an
|
|
20
|
+
* already-posted entry is posted and escrowed a second time. Bare hex is the realistic mistake —
|
|
21
|
+
* it is the in-toto DigestSet form this stack writes elsewhere by law (program §5 contract 6) — so
|
|
22
|
+
* the list is validated rather than trusted, and a non-canonical entry fails the plan closed
|
|
23
|
+
* instead of quietly dropping out of the comparison.
|
|
24
|
+
*/
|
|
25
|
+
function digestSet(values, field) {
|
|
26
|
+
const digests = new Set();
|
|
27
|
+
for (const value of values ?? []) {
|
|
28
|
+
if (!PREFIXED_SHA256_PATTERN.test(value)) {
|
|
29
|
+
throw new Error(`policy.${field} entry ${JSON.stringify(value)} is not a sha256:-prefixed lower-case `
|
|
30
|
+
+ "64-hex digest -- it would match no pool entry, so the batch would post and escrow the "
|
|
31
|
+
+ "entry this list was written to hold back");
|
|
32
|
+
}
|
|
33
|
+
digests.add(value);
|
|
34
|
+
}
|
|
35
|
+
return digests;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Selects and orders the entries this batch posts.
|
|
39
|
+
*
|
|
40
|
+
* @param pool the materialized pool listing (finding F-C5-2: the caller reads `SupplyPool.list()`
|
|
41
|
+
* and passes the result; a pure function cannot await a store).
|
|
42
|
+
*/
|
|
43
|
+
export function planPosting(pool, policy) {
|
|
44
|
+
if (!Number.isInteger(policy.batchLimit) || policy.batchLimit < 1) {
|
|
45
|
+
throw new RangeError(`batchLimit must be a positive integer, got ${String(policy.batchLimit)}`);
|
|
46
|
+
}
|
|
47
|
+
if (!Number.isInteger(policy.deadlineSeconds) || policy.deadlineSeconds < 1) {
|
|
48
|
+
throw new RangeError(`deadlineSeconds must be a positive integer, got ${String(policy.deadlineSeconds)}`);
|
|
49
|
+
}
|
|
50
|
+
const deadline = offsetIso(policy.now, policy.deadlineSeconds);
|
|
51
|
+
const closeAt = policy.closeAtSeconds === undefined
|
|
52
|
+
? undefined
|
|
53
|
+
: offsetIso(policy.now, policy.closeAtSeconds);
|
|
54
|
+
const excluded = digestSet(policy.excludedTaskDigests, "excludedTaskDigests");
|
|
55
|
+
const posted = digestSet(policy.postedTaskDigests, "postedTaskDigests");
|
|
56
|
+
const skipped = [];
|
|
57
|
+
const eligible = [];
|
|
58
|
+
for (const entry of pool) {
|
|
59
|
+
if (excluded.has(entry.taskDigest)) {
|
|
60
|
+
skipped.push({ taskDigest: entry.taskDigest, reason: "excluded" });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!entry.evaluationSpecPublic) {
|
|
64
|
+
skipped.push({ taskDigest: entry.taskDigest, reason: "evaluation-not-public" });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const repost = posted.has(entry.taskDigest);
|
|
68
|
+
if (repost && policy.repostPosted !== true) {
|
|
69
|
+
skipped.push({ taskDigest: entry.taskDigest, reason: "already-posted" });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
eligible.push({ entry, repost });
|
|
73
|
+
}
|
|
74
|
+
// Never-posted first; within a group, code-unit order by digest so two hosts plan one batch.
|
|
75
|
+
eligible.sort((left, right) => {
|
|
76
|
+
if (left.repost !== right.repost)
|
|
77
|
+
return left.repost ? 1 : -1;
|
|
78
|
+
return compareCodeUnitStrings(left.entry.taskDigest, right.entry.taskDigest);
|
|
79
|
+
});
|
|
80
|
+
const entries = [];
|
|
81
|
+
for (const candidate of eligible) {
|
|
82
|
+
if (entries.length >= policy.batchLimit) {
|
|
83
|
+
skipped.push({ taskDigest: candidate.entry.taskDigest, reason: "batch-limit" });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
entries.push({
|
|
87
|
+
taskDigest: candidate.entry.taskDigest,
|
|
88
|
+
deadline,
|
|
89
|
+
...(closeAt === undefined ? {} : { closeAt }),
|
|
90
|
+
maxClaims: policy.terms.maxClaims,
|
|
91
|
+
escrowValueWei: postingEscrowValueWei(policy.terms),
|
|
92
|
+
repost: candidate.repost,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
createdAt: policy.now,
|
|
97
|
+
creatorSafe: policy.creatorSafe,
|
|
98
|
+
requester: policy.requester,
|
|
99
|
+
terms: policy.terms,
|
|
100
|
+
approval: policy.autoPost === true ? "auto" : "explicit",
|
|
101
|
+
entries,
|
|
102
|
+
totalEscrowValueWei: entries.reduce((total, planned) => total + planned.escrowValueWei, 0n),
|
|
103
|
+
skipped,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PostingPoolEntry } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* A pool entry as the supply pool stores it: the sealed pair's bytes, the digests that address
|
|
4
|
+
* them, and the digest of the admission receipt the pair earned.
|
|
5
|
+
*/
|
|
6
|
+
export interface SuppliedPoolEntry {
|
|
7
|
+
readonly taskDigest: `sha256:${string}`;
|
|
8
|
+
readonly taskBytes: Uint8Array;
|
|
9
|
+
readonly evaluationSpecDigest: `sha256:${string}`;
|
|
10
|
+
readonly evaluationSpecBytes: Uint8Array;
|
|
11
|
+
/** C4's name for the admission receipt this pair earned. */
|
|
12
|
+
readonly receiptDigest: `sha256:${string}`;
|
|
13
|
+
}
|
|
14
|
+
export interface PostingPoolEntryOptions {
|
|
15
|
+
/** Advisory acquisition hint for the receipt; the digest is what binds. */
|
|
16
|
+
readonly admissionReceiptUri?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Whether every access-classified descriptor in a sealed EvaluationSpec is stamped public.
|
|
20
|
+
*
|
|
21
|
+
* A specification with no stamps at all is `false`: absence of a stamp is not evidence of
|
|
22
|
+
* publicness, and v1 posts public-specification evaluation legs only (design §8, D5). The walk is
|
|
23
|
+
* family-agnostic on purpose — it reads the stamps wherever a family block puts them rather than
|
|
24
|
+
* hard-coding one family's shape into the posting application.
|
|
25
|
+
*/
|
|
26
|
+
export declare function evaluationSpecIsPublic(evaluationSpecBytes: Uint8Array): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Builds the entry this application posts from the entry the pool stores.
|
|
29
|
+
*
|
|
30
|
+
* Both digests are checked against the bytes they address before anything is read from those
|
|
31
|
+
* bytes: a publicness stamp read out of a specification the entry does not name would be a claim
|
|
32
|
+
* about some other document.
|
|
33
|
+
*/
|
|
34
|
+
export declare function postingPoolEntry(entry: SuppliedPoolEntry, options?: PostingPoolEntryOptions): PostingPoolEntry;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// The reconciliation between what C4's pool stores and what this application posts, filed as
|
|
3
|
+
// F-C5-8 (see README, "Reported to the program"). C4's `PoolEntry` names the admission receipt
|
|
4
|
+
// `receiptDigest` and models no publicness flag at all, while spec §8's D5 gate — the one
|
|
5
|
+
// `planPosting` and `buildDispatchSubmission` both enforce — needs `admissionReceiptDigest` and
|
|
6
|
+
// `evaluationSpecPublic`. Without this adapter the tier-4 composition would have to invent both,
|
|
7
|
+
// and the D5 refusal would rest on a caller's assertion rather than on the sealed bytes.
|
|
8
|
+
//
|
|
9
|
+
// So `evaluationSpecPublic` is read here, off the EvaluationSpec bytes the entry's own digest
|
|
10
|
+
// addresses: D5's stamping puts `accessClass: "public"` on every access-classified descriptor at
|
|
11
|
+
// seal time, and this reads those stamps back. Fail closed in every direction — an unstamped
|
|
12
|
+
// specification, a private stamp, a non-string stamp, or bytes that are not a JSON document all
|
|
13
|
+
// yield `false`, which `planPosting` records as the `evaluation-not-public` skip.
|
|
14
|
+
//
|
|
15
|
+
// No runtime edge into `@jinn-network/task-derivation` (finding F-C5-5): the input is declared
|
|
16
|
+
// structurally here and `pool-shape.test.ts` pins C4's stored entry against it at compile time.
|
|
17
|
+
import { sha256Hex } from "@jinn-network/task-execution-protocol";
|
|
18
|
+
import { assertPrefixedSha256 } from "./digest.js";
|
|
19
|
+
const ACCESS_CLASS_KEY = "accessClass";
|
|
20
|
+
const PUBLIC_ACCESS_CLASS = "public";
|
|
21
|
+
function collectAccessClasses(node, found) {
|
|
22
|
+
if (Array.isArray(node)) {
|
|
23
|
+
for (const item of node)
|
|
24
|
+
collectAccessClasses(item, found);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (node === null || typeof node !== "object")
|
|
28
|
+
return;
|
|
29
|
+
for (const [key, value] of Object.entries(node)) {
|
|
30
|
+
if (key === ACCESS_CLASS_KEY) {
|
|
31
|
+
found.push(value);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
collectAccessClasses(value, found);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Whether every access-classified descriptor in a sealed EvaluationSpec is stamped public.
|
|
39
|
+
*
|
|
40
|
+
* A specification with no stamps at all is `false`: absence of a stamp is not evidence of
|
|
41
|
+
* publicness, and v1 posts public-specification evaluation legs only (design §8, D5). The walk is
|
|
42
|
+
* family-agnostic on purpose — it reads the stamps wherever a family block puts them rather than
|
|
43
|
+
* hard-coding one family's shape into the posting application.
|
|
44
|
+
*/
|
|
45
|
+
export function evaluationSpecIsPublic(evaluationSpecBytes) {
|
|
46
|
+
let document;
|
|
47
|
+
try {
|
|
48
|
+
document = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(evaluationSpecBytes));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const found = [];
|
|
54
|
+
collectAccessClasses(document, found);
|
|
55
|
+
if (found.length === 0)
|
|
56
|
+
return false;
|
|
57
|
+
return found.every((value) => value === PUBLIC_ACCESS_CLASS);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Builds the entry this application posts from the entry the pool stores.
|
|
61
|
+
*
|
|
62
|
+
* Both digests are checked against the bytes they address before anything is read from those
|
|
63
|
+
* bytes: a publicness stamp read out of a specification the entry does not name would be a claim
|
|
64
|
+
* about some other document.
|
|
65
|
+
*/
|
|
66
|
+
export function postingPoolEntry(entry, options = {}) {
|
|
67
|
+
assertPrefixedSha256(entry.taskDigest, "taskDigest");
|
|
68
|
+
assertPrefixedSha256(entry.evaluationSpecDigest, "evaluationSpecDigest");
|
|
69
|
+
assertPrefixedSha256(entry.receiptDigest, "receiptDigest");
|
|
70
|
+
const taskHex = sha256Hex(entry.taskBytes);
|
|
71
|
+
if (taskHex !== entry.taskDigest.slice("sha256:".length)) {
|
|
72
|
+
throw new Error(`pool entry taskDigest ${entry.taskDigest} does not address its bytes (sha256:${taskHex})`);
|
|
73
|
+
}
|
|
74
|
+
const specHex = sha256Hex(entry.evaluationSpecBytes);
|
|
75
|
+
if (specHex !== entry.evaluationSpecDigest.slice("sha256:".length)) {
|
|
76
|
+
throw new Error(`pool entry evaluationSpecDigest ${entry.evaluationSpecDigest} does not address its bytes `
|
|
77
|
+
+ `(sha256:${specHex}) -- the access class read from those bytes would describe another `
|
|
78
|
+
+ "specification");
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
taskDigest: entry.taskDigest,
|
|
82
|
+
taskBytes: entry.taskBytes,
|
|
83
|
+
evaluationSpecDigest: entry.evaluationSpecDigest,
|
|
84
|
+
admissionReceiptDigest: entry.receiptDigest,
|
|
85
|
+
...(options.admissionReceiptUri === undefined
|
|
86
|
+
? {}
|
|
87
|
+
: { admissionReceiptUri: options.admissionReceiptUri }),
|
|
88
|
+
evaluationSpecPublic: evaluationSpecIsPublic(entry.evaluationSpecBytes),
|
|
89
|
+
};
|
|
90
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { DefaultPostingTerms } from "@jinn-network/marketplace-binding";
|
|
2
|
+
/**
|
|
3
|
+
* One admitted, sealed pair as this application needs it. Structurally what C4's `SupplyPool`
|
|
4
|
+
* listing yields; declared here because §3.1 makes this package usable by "any requester with
|
|
5
|
+
* sealed pairs from any source", not only by C4's pool.
|
|
6
|
+
*
|
|
7
|
+
* `admissionReceiptDigest` is required: the dispatch Submission must carry the admission-receipt
|
|
8
|
+
* descriptor, or the evaluation leg derived from it later is refused (binding §7.39; plan finding
|
|
9
|
+
* F-C5-4).
|
|
10
|
+
*/
|
|
11
|
+
export interface PostingPoolEntry {
|
|
12
|
+
readonly taskDigest: `sha256:${string}`;
|
|
13
|
+
readonly taskBytes: Uint8Array;
|
|
14
|
+
readonly evaluationSpecDigest: `sha256:${string}`;
|
|
15
|
+
readonly admissionReceiptDigest: `sha256:${string}`;
|
|
16
|
+
readonly admissionReceiptUri?: string;
|
|
17
|
+
/** v1 posts public-specification evaluation legs only (design §8, D5); false is refused. */
|
|
18
|
+
readonly evaluationSpecPublic: boolean;
|
|
19
|
+
}
|
|
20
|
+
export type PostingSkipReason = "excluded" | "already-posted" | "batch-limit" | "evaluation-not-public";
|
|
21
|
+
export interface PostingSkip {
|
|
22
|
+
readonly taskDigest: `sha256:${string}`;
|
|
23
|
+
readonly reason: PostingSkipReason;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Supply policy. `now` is an input, not a clock read: the sealed Submission is a function of the
|
|
27
|
+
* plan, so a plan built twice from the same inputs yields byte-identical bytes and therefore the
|
|
28
|
+
* same broadcast-intent key. A hidden clock would turn every replay into a new post.
|
|
29
|
+
*/
|
|
30
|
+
export interface PostingPolicy {
|
|
31
|
+
readonly terms: DefaultPostingTerms;
|
|
32
|
+
readonly creatorSafe: `0x${string}`;
|
|
33
|
+
/** The requester of record recorded in every Submission this plan seals. */
|
|
34
|
+
readonly requester: string;
|
|
35
|
+
/** RFC 3339 with an explicit offset. */
|
|
36
|
+
readonly now: string;
|
|
37
|
+
readonly deadlineSeconds: number;
|
|
38
|
+
readonly closeAtSeconds?: number;
|
|
39
|
+
readonly batchLimit: number;
|
|
40
|
+
readonly excludedTaskDigests?: readonly string[];
|
|
41
|
+
/** Digests already posted by this requester; never-posted entries always plan first. */
|
|
42
|
+
readonly postedTaskDigests?: readonly string[];
|
|
43
|
+
/** Default false: an already-posted entry is dropped, not merely deprioritized. */
|
|
44
|
+
readonly repostPosted?: boolean;
|
|
45
|
+
/** Opt-in standing approval. The plan still renders and still logs its terms and escrow. */
|
|
46
|
+
readonly autoPost?: boolean;
|
|
47
|
+
}
|
|
48
|
+
export interface PostingPlanEntry {
|
|
49
|
+
readonly taskDigest: `sha256:${string}`;
|
|
50
|
+
readonly deadline: string;
|
|
51
|
+
readonly closeAt?: string;
|
|
52
|
+
readonly maxClaims: number;
|
|
53
|
+
readonly escrowValueWei: bigint;
|
|
54
|
+
/** True when this digest appears in `postedTaskDigests` and reposting was allowed. */
|
|
55
|
+
readonly repost: boolean;
|
|
56
|
+
}
|
|
57
|
+
export interface PostingPlan {
|
|
58
|
+
readonly createdAt: string;
|
|
59
|
+
readonly creatorSafe: `0x${string}`;
|
|
60
|
+
readonly requester: string;
|
|
61
|
+
readonly terms: DefaultPostingTerms;
|
|
62
|
+
readonly approval: "explicit" | "auto";
|
|
63
|
+
readonly entries: readonly PostingPlanEntry[];
|
|
64
|
+
readonly totalEscrowValueWei: bigint;
|
|
65
|
+
readonly skipped: readonly PostingSkip[];
|
|
66
|
+
}
|
package/dist/types.js
ADDED
package/package.json
CHANGED
|
@@ -1,13 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jinn-network/task-posting",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b",
|
|
4
|
+
"description": "Supply policy for marketplace posting: which admitted, sealed task pairs post, when, at what terms, with the escrow surfaced before spending.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"packageManager": "yarn@4.13.0",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
6
11
|
"repository": {
|
|
7
12
|
"type": "git",
|
|
8
|
-
"url": "https://github.com/Jinn-Network/mono.git"
|
|
13
|
+
"url": "https://github.com/Jinn-Network/mono.git",
|
|
14
|
+
"directory": "packages/task-supply/posting"
|
|
15
|
+
},
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"import": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts"
|
|
22
|
+
}
|
|
9
23
|
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist/",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
10
28
|
"publishConfig": {
|
|
11
29
|
"access": "public"
|
|
12
|
-
}
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "node scripts/build.mjs",
|
|
33
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"pack:smoke": "node scripts/pack-smoke.mjs",
|
|
36
|
+
"prepack": "yarn build"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@jinn-network/marketplace-binding": "0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b",
|
|
40
|
+
"@jinn-network/task-execution-protocol": "0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@jinn-network/task-derivation": "0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b",
|
|
44
|
+
"@jinn-network/task-execution-backend": "0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b",
|
|
45
|
+
"@jinn-network/task-execution-profiles": "0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b",
|
|
46
|
+
"@jinn-network/trust-core": "0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b",
|
|
47
|
+
"@jinn-network/trust-resolve": "0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b",
|
|
48
|
+
"@types/node": "^22.0.0",
|
|
49
|
+
"typescript": "^5.9.3",
|
|
50
|
+
"vitest": "^4.1.8"
|
|
51
|
+
},
|
|
52
|
+
"gitHead": "786221faf848b3c859ddbc76fec09338df64d83b"
|
|
13
53
|
}
|