wai: the revenue share — share contract and statement, recomputable from the ledger's invoices and the channel's program-as-run (wai.delivery.revenue_share) #539
25 changed files with 9923 additions and 7 deletions
11
.github/workflows/wai-catalogue.yml
vendored
11
.github/workflows/wai-catalogue.yml
vendored
|
|
@ -28,6 +28,8 @@ on:
|
|||
- 'wai/http-conformance/**'
|
||||
- 'wai/score-conformance/**'
|
||||
- 'wai/live-conformance/fleet/**'
|
||||
# tests/cli_ops.rs builds the revenue share's --dry-run sample from this corpus.
|
||||
- 'wai/live-conformance/share/**'
|
||||
- 'wai/REGISTRY.md'
|
||||
- 'wai/LICENSE'
|
||||
- 'wai/wai-quantum/**'
|
||||
|
|
@ -48,6 +50,8 @@ on:
|
|||
- 'wai/http-conformance/**'
|
||||
- 'wai/score-conformance/**'
|
||||
- 'wai/live-conformance/fleet/**'
|
||||
# tests/cli_ops.rs builds the revenue share's --dry-run sample from this corpus.
|
||||
- 'wai/live-conformance/share/**'
|
||||
- 'wai/REGISTRY.md'
|
||||
- 'wai/LICENSE'
|
||||
- 'wai/wai-quantum/**'
|
||||
|
|
@ -118,8 +122,9 @@ jobs:
|
|||
# tests/cli_ops.rs: wai ops, help, the exit codes, the envelope, --dry-run and --yes (and
|
||||
# --confirm, its deprecated spelling),
|
||||
# first in the archive's build (quickstart), then with every command-line feature that
|
||||
# needs no system library, so --dry-run is held on generate, score and stage too, and with
|
||||
# Create's (app), so it is held on wai app export.
|
||||
# needs no system library, so --dry-run is held on generate, score and stage too, with
|
||||
# Create's (app), so it is held on wai app export, and with the revenue share's
|
||||
# (revenue_share), so it is held on wai contract create|accept and wai statement create.
|
||||
- name: The catalogue's rules, and the command line through it
|
||||
shell: bash
|
||||
working-directory: wai/wai-rs
|
||||
|
|
@ -134,3 +139,5 @@ jobs:
|
|||
ran "$RUNNER_TEMP/cli-full.log"
|
||||
cargo test --locked --no-default-features --features quickstart,app --test cli_ops 2>&1 | tee "$RUNNER_TEMP/cli-app.log"
|
||||
ran "$RUNNER_TEMP/cli-app.log"
|
||||
cargo test --locked --no-default-features --features quickstart,revenue_share --test cli_ops 2>&1 | tee "$RUNNER_TEMP/cli-share.log"
|
||||
ran "$RUNNER_TEMP/cli-share.log"
|
||||
|
|
|
|||
34
.github/workflows/wai-conformance.yml
vendored
34
.github/workflows/wai-conformance.yml
vendored
|
|
@ -195,6 +195,11 @@ on:
|
|||
# presentation and stitched corpora and the packs of pack-conformance, all under the paths
|
||||
# above.
|
||||
- 'wai/extensions/receipt-ledger.md'
|
||||
# tests/revenue_share.rs requires this extension's §11 to list the codes the reference
|
||||
# reports, revenue_share_verify.py reads that table, and the registry's wire identifiers
|
||||
# test reads its sections. Its corpus is generated from the ledger corpus, under the paths
|
||||
# above.
|
||||
- 'wai/extensions/revenue-share.md'
|
||||
# tests/stream_audit.rs requires this extension's §14 to list the codes the reference
|
||||
# reports, stream_audit_verify.py reads that table, and the registry's wire identifiers test
|
||||
# reads its sections.
|
||||
|
|
@ -1155,7 +1160,7 @@ jobs:
|
|||
name: independent verifiers · claims, JWP, key discovery and integer payloads (Python)
|
||||
runs-on: linux-arm64
|
||||
env:
|
||||
WAI_FEATURES: key_discovery,http,video_manifest,stage_measure,live_events,live_room,capture_take,live_compose,live_record,timed_text,access_services,live_metering,energy_steering,live_moderation,dred_exact,live_ads,live_provenance,synthesis,synthesis_mark,neural_int,exact_upscale,robustness,ai_usage,contribution,settlement,browser_sink,receipt_claims,takedown,influence,direction,co_performance,age_assurance,bearer_receipts,exploration,co_watch,telemetry_aggregate,exact_tonemap,splat_exact,live_watermark,derive_edge,sink_energy,key_release,stitched_presentation,prior_wire,scene_map,avatar_face,receipt_ledger,stream_audit,fleet,native_sink,platform_encode,remote_desktop,stream_termination,live_world,format_probe,live_messaging,conform_result
|
||||
WAI_FEATURES: key_discovery,http,video_manifest,stage_measure,live_events,live_room,capture_take,live_compose,live_record,timed_text,access_services,live_metering,energy_steering,live_moderation,dred_exact,live_ads,live_provenance,synthesis,synthesis_mark,neural_int,exact_upscale,robustness,ai_usage,contribution,settlement,browser_sink,receipt_claims,takedown,influence,direction,co_performance,age_assurance,bearer_receipts,exploration,co_watch,telemetry_aggregate,exact_tonemap,splat_exact,live_watermark,derive_edge,sink_energy,key_release,stitched_presentation,prior_wire,scene_map,avatar_face,receipt_ledger,stream_audit,fleet,native_sink,platform_encode,remote_desktop,stream_termination,live_world,format_probe,live_messaging,conform_result,revenue_share
|
||||
WAI_PQ_FEATURES: synthesis,pq_signatures
|
||||
WAI_RELEASE_FEATURES: dred_exact,synthesis_mark,robustness,avatar_face
|
||||
steps:
|
||||
|
|
@ -1250,7 +1255,7 @@ jobs:
|
|||
--test stitched_presentation --test prior_wire --test scene_map --test receipt_ledger \
|
||||
--test stream_audit --test fleet --test native_sink --test platform_encode --test remote_desktop \
|
||||
--test stream_termination --test live_world --test format_probe --test live_messaging \
|
||||
--test conform_result
|
||||
--test conform_result --test revenue_share
|
||||
cargo test --release --no-run --no-default-features --features "$WAI_RELEASE_FEATURES" \
|
||||
--test dred_exact --test dred_exact_measure --test synthesis_mark --test robustness \
|
||||
--test avatar_face
|
||||
|
|
@ -1986,6 +1991,29 @@ jobs:
|
|||
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib receipt_ledger::)
|
||||
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test receipt_ledger)
|
||||
python3 -I live-conformance/receipt_ledger_verify.py live-conformance/ledger
|
||||
# wai.delivery.revenue_share (extensions/revenue-share.md): the reference module's unit tests
|
||||
# (the largest-remainder division and its ties, the date-time a marker states, the contract's
|
||||
# and the statement's reading and rules, contracts that overlap) and the channel receipt's
|
||||
# JSON carriage; tests/revenue_share.rs (vectors.json regenerates byte for byte from its fixed
|
||||
# seeds and the ledger corpus, and no other file is there; wai-rs gives every vector its
|
||||
# stated outcome; every reason of §11 and every reused code refuses a vector; §11 lists exactly
|
||||
# the reference's codes; the worked example, three statements of one month computed and then
|
||||
# recomputed from the public records alone; the four operations from the command line); then
|
||||
# the corpus's second implementation, revenue_share_verify.py, written from the extension's
|
||||
# text and sharing no code with wai-rs, which verifies each ledger document with
|
||||
# receipt_ledger_verify.py, reads every contract, run, marker and statement itself,
|
||||
# recomputes every attribution, line, division and citation, requires every mutated copy of
|
||||
# the corpus to fail, and drops each of its own checks in turn and requires the corpus to
|
||||
# catch the weakened verifier; standard library only, run isolated (-I).
|
||||
- name: revenue share · wai-rs and revenue_share_verify.py
|
||||
working-directory: wai
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib revenue_share::)
|
||||
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib wai_video_channel::)
|
||||
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test revenue_share)
|
||||
python3 -I live-conformance/revenue_share_verify.py live-conformance/share
|
||||
# wai.delivery.audit (extensions/stream-audit.md): the reference module's unit tests (the
|
||||
# acknowledgement windows, chains and repeats of §4.2, the origin rule, the report's bytes,
|
||||
# channel names) and tests/stream_audit.rs (vectors.json regenerates byte for byte from its
|
||||
|
|
@ -2664,7 +2692,7 @@ jobs:
|
|||
synthesis,pq_signatures sink_energy key_release stitched_presentation prior_wire \
|
||||
prior_wire,neural_int scene_map avatar_face receipt_ledger stream_audit fleet native_sink \
|
||||
platform_encode remote_desktop stream_termination live_world format_probe live_messaging \
|
||||
conform_result; do
|
||||
conform_result revenue_share; do
|
||||
echo "::group::$f"
|
||||
cargo check --no-default-features --features "$f" --tests
|
||||
echo "::endgroup::"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,34 @@ wire format may change and such changes are recorded here.
|
|||
|
||||
## Unreleased
|
||||
|
||||
### The revenue share: what each owner of a channel is owed, recomputable from the public records
|
||||
|
||||
- **`wai.delivery.revenue_share`** ([`extensions/revenue-share.md`](extensions/revenue-share.md)), a new record. An
|
||||
operator and every owner it names sign a **share contract**, `WRC1`: its period and currency, its channel and the key
|
||||
that signs the channel's program-as-run, its carriers (the rooms of `wai.live.ads` and the stitchers of
|
||||
`wai.video.stitched_presentation` whose evidence is the channel's), each asset's split of integer millionths among
|
||||
owners and the operator, and the rule for a break between programmes (preceding, following, or a between split). A
|
||||
contract is in force only when every owner has countersigned it. The operator signs a **statement**, `WRS1`, for one
|
||||
party and one period: it rests on invoices of `wai.delivery.ledger`, each re-verified by the ledger, and on the
|
||||
channel's program-as-run windows; each impression the invoices bill from the contract's carriers is placed at its
|
||||
instant (a break's start, or a stitched marker's `START-DATE`) on the window that aired it and attributed to the
|
||||
programme on air; each line, per split and class (presented, beacons, unplaced), states the impressions, the revenue
|
||||
their invoices' tariffs priced them at, and the party's part by the largest-remainder rule, so that the parts of a
|
||||
split sum to its revenue exactly, with a citation digest of its records.
|
||||
- **`wai.video.channel` receipts gain a JSON carriage** (revenue-share §4), `ChannelReceipt::to_json` and `from_json`,
|
||||
which write out the receipt's signed bytes and identity for a verifier that is not `wai-rs`. The signed bytes and
|
||||
every existing receipt are unchanged.
|
||||
- **Operations** `contract.create` (`wai contract create`), `contract.update` (`wai contract accept`, an owner's
|
||||
countersignature), `statement.create` (`wai statement create`) and `statement.verify` (`wai statement verify`), in a
|
||||
build with the feature `revenue_share`, under the agent rules (`--json`, the exit codes, `--dry-run`, `--yes` before
|
||||
a file is replaced).
|
||||
- **Conformance**: `live-conformance/share/`, written by `wai_revenue_share_vectors` from fixed seeds and the ledger
|
||||
corpus's sources (a room's break-form receipts under a policy that requires claims and under one that does not, and
|
||||
three streams stitched by others); accepted statements of two owners and the operator over one month, and under each
|
||||
rule; a refused vector for each reason. Its second implementation, `live-conformance/revenue_share_verify.py`,
|
||||
verifies each ledger document with `receipt_ledger_verify.py` and recomputes every attribution, line, division and
|
||||
citation itself.
|
||||
|
||||
### wai-quantum for JavaScript: the engine as an npm package
|
||||
|
||||
- **`@transaction-science/wai-quantum` 0.1.0** (`wai/wai-quantum-js/`): the job service's engine for WebAssembly
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ and a role are fixed once registered.
|
|||
| `wai.delivery.audit` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.delivery.fleet` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.delivery.ledger` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.delivery.revenue_share` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.delivery.settlement` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.derive.crop` | derivation | registered | — | — | patent-pool | — | — | §5 Prior artifacts, general tensors and derivation operations |
|
||||
| `wai.derive.dissolve` | derivation | registered | — | — | patent-pool | — | — | §5 Prior artifacts, general tensors and derivation operations |
|
||||
|
|
@ -426,11 +427,13 @@ and a role are fixed once registered.
|
|||
| `WPW1` | magic | defined | co-performance §7.2 |
|
||||
| `WQA1` | magic | defined | telemetry-aggregation §7.1 |
|
||||
| `WQS1` | magic | defined | telemetry-aggregation §6.1 |
|
||||
| `WRC1` | magic | defined | revenue-share §3.1 |
|
||||
| `WRD1` | magic | defined | live-rooms §3.1 |
|
||||
| `WRE1` | magic | defined | confidentiality §3.1 |
|
||||
| `WRG1` | magic | defined | live-rooms §4.1 |
|
||||
| `WRM1` | magic | defined | live-recording §4.1 |
|
||||
| `WRP1` | magic | defined | live-rooms §10.5.1 |
|
||||
| `WRS1` | magic | defined | revenue-share §7.1 |
|
||||
| `WSC1` | magic | defined | stitched-presentation §5.1 |
|
||||
| `WSD1` | magic | defined | energy-steering §7.1 |
|
||||
| `WSI1` | magic | defined | stitched-presentation §4.1 |
|
||||
|
|
|
|||
18
wai/SPEC.md
18
wai/SPEC.md
|
|
@ -1526,6 +1526,24 @@ the presentation claims, and each delivering party's acknowledged, free and decl
|
|||
bytes two parties were acknowledged for by one receiver. No payment and no tax:
|
||||
[`extensions/receipt-ledger.md`](extensions/receipt-ledger.md).
|
||||
|
||||
And for **sharing a channel's ad revenue with the owners of its programmes**, statements an owner
|
||||
can recompute from the public records — `wai.delivery.revenue_share`: an operator and every owner
|
||||
it names sign a share contract, `WRC1`: its period and currency, its channel and the key that signs
|
||||
the channel's program-as-run (`wai.video.channel`, whose receipt it reads in a JSON carriage that
|
||||
writes out its signed bytes unchanged), the rooms and stitchers whose ad evidence is the channel's,
|
||||
the map from each asset to a split of integer millionths among owners and the operator, and the
|
||||
rule for a break between programmes (the programme before it, the one after it, or a between split).
|
||||
The operator signs a statement, `WRS1`, for one party and one period: the invoices of
|
||||
`wai.delivery.ledger` it rests on, each verified by the ledger, the program-as-run windows it places
|
||||
impressions in, and one line per split and class (presented under the players' claims, billable on
|
||||
a decider's beacons, or presented with no time a marker states), each with its impressions, the
|
||||
revenue its invoices' tariffs priced them at, the party's part of it by the largest-remainder rule,
|
||||
so that the parts of a split sum to its revenue exactly, and a citation digest of the records it
|
||||
came from. A verifier refuses an impression outside every aired window, an asset the contract does
|
||||
not map, a contract an owner did not countersign, two contracts that overlap, an impression two
|
||||
invoices bill, a split whose shares do not sum, and a line no record covers. No payment and no
|
||||
currency conversion: [`extensions/revenue-share.md`](extensions/revenue-share.md).
|
||||
|
||||
And for **auditing a stream another service delivers** — `wai.delivery.audit`: a content owner's
|
||||
players, with nothing asked of the service that packages and delivers the stream over HTTP. For each
|
||||
playback session a player makes a fresh key and signs with it settlement acknowledgements of every
|
||||
|
|
|
|||
|
|
@ -6081,6 +6081,353 @@
|
|||
"sdk": "wai.call.show"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "contract.create",
|
||||
"summary": "A channel's share contract from its terms, signed with the operator key; each owner countersigns it",
|
||||
"cli": "wai contract create",
|
||||
"aliases": [],
|
||||
"usage": [
|
||||
"wai contract create <terms.json> --key <file> [--out F] [--yes] the share contract (WRC1) of the terms, signed",
|
||||
" with the operator key, every countersignature still to come, written to F (<digest>.wrc); each owner adds",
|
||||
" its own with `wai contract accept`; replacing a file waits for --yes (--confirm, deprecated, is the same)"
|
||||
],
|
||||
"placement": "local",
|
||||
"effect": "writes-project",
|
||||
"dry_run": true,
|
||||
"confirm": "when-replacing-a-file",
|
||||
"route": null,
|
||||
"doors": {
|
||||
"cli": true,
|
||||
"mcp": false,
|
||||
"http": false
|
||||
},
|
||||
"toolset": "core",
|
||||
"stability": "preview",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"terms": {
|
||||
"type": "string",
|
||||
"format": "path",
|
||||
"description": "The terms: start, end, currency, channel, channel_signer, rule, between, carriers, owners, splits and assets.",
|
||||
"x-cli": {
|
||||
"positional": true
|
||||
}
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"format": "path",
|
||||
"description": "The operator key's file: 32 bytes, or 64 hexadecimal digits.",
|
||||
"x-cli": {
|
||||
"flag": "--key"
|
||||
}
|
||||
},
|
||||
"out": {
|
||||
"type": "string",
|
||||
"format": "path",
|
||||
"description": "The contract's file (<digest>.wrc).",
|
||||
"x-cli": {
|
||||
"flag": "--out"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"terms",
|
||||
"key"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"digest",
|
||||
"operator",
|
||||
"channel",
|
||||
"owners",
|
||||
"in_force",
|
||||
"contract",
|
||||
"file"
|
||||
],
|
||||
"description": "The share contract (WRC1, hexadecimal), its digest, each owner and whether it has countersigned, and the file it went to"
|
||||
},
|
||||
"stream": null,
|
||||
"since": "0.3.0",
|
||||
"deprecated": null,
|
||||
"receipt": true,
|
||||
"needs": {
|
||||
"all": [
|
||||
"revenue_share"
|
||||
]
|
||||
},
|
||||
"names": {
|
||||
"cli": "wai contract create",
|
||||
"mcp": null,
|
||||
"c": "wai_contract_create",
|
||||
"rust": "wai::contract::create",
|
||||
"sdk": "wai.contract.create"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "contract.update",
|
||||
"summary": "An owner's countersignature added to a share contract, after its rules and the operator's signature are checked",
|
||||
"cli": "wai contract accept",
|
||||
"aliases": [],
|
||||
"usage": [
|
||||
"wai contract accept <contract> --key <file> [--out F] [--yes] the owner's countersignature added to the contract,",
|
||||
" written to F (the contract's own file when F is not given); replacing a file waits for --yes (--confirm,",
|
||||
" deprecated, is the same)"
|
||||
],
|
||||
"placement": "local",
|
||||
"effect": "writes-project",
|
||||
"dry_run": true,
|
||||
"confirm": "when-replacing-a-file",
|
||||
"route": null,
|
||||
"doors": {
|
||||
"cli": true,
|
||||
"mcp": false,
|
||||
"http": false
|
||||
},
|
||||
"toolset": "core",
|
||||
"stability": "preview",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"contract": {
|
||||
"type": "string",
|
||||
"format": "path",
|
||||
"description": "The contract's file (WRC1).",
|
||||
"x-cli": {
|
||||
"positional": true
|
||||
}
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"format": "path",
|
||||
"description": "The owner key's file: 32 bytes, or 64 hexadecimal digits.",
|
||||
"x-cli": {
|
||||
"flag": "--key"
|
||||
}
|
||||
},
|
||||
"out": {
|
||||
"type": "string",
|
||||
"format": "path",
|
||||
"description": "Where the countersigned contract goes (the contract's own file).",
|
||||
"x-cli": {
|
||||
"flag": "--out"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"contract",
|
||||
"key"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"digest",
|
||||
"operator",
|
||||
"channel",
|
||||
"owners",
|
||||
"in_force",
|
||||
"contract",
|
||||
"file"
|
||||
],
|
||||
"description": "The share contract (WRC1, hexadecimal), its digest, each owner and whether it has countersigned, and the file it went to"
|
||||
},
|
||||
"stream": null,
|
||||
"since": "0.3.0",
|
||||
"deprecated": null,
|
||||
"receipt": true,
|
||||
"needs": {
|
||||
"all": [
|
||||
"revenue_share"
|
||||
]
|
||||
},
|
||||
"names": {
|
||||
"cli": "wai contract update",
|
||||
"mcp": null,
|
||||
"c": "wai_contract_update",
|
||||
"rust": "wai::contract::update",
|
||||
"sdk": "wai.contract.update"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "statement.create",
|
||||
"summary": "A party's revenue-share statement for a period, recomputed from the ledger's invoices and the channel's program-as-run, signed with the operator key",
|
||||
"cli": "wai statement create",
|
||||
"aliases": [],
|
||||
"usage": [
|
||||
"wai statement create <inputs.json> --party <key> --start µs --end µs --key <file> [--out F] [--yes] the statement",
|
||||
" (WRS1) of the party for [start, end), computed from a verification document without its statement (the",
|
||||
" contract, other contracts, runs and ledger documents), signed with the operator key and written with its",
|
||||
" document to F (<digest>.wai-share.json) for `wai statement verify`; replacing a file waits for --yes"
|
||||
],
|
||||
"placement": "local",
|
||||
"effect": "writes-project",
|
||||
"dry_run": true,
|
||||
"confirm": "when-replacing-a-file",
|
||||
"route": null,
|
||||
"doors": {
|
||||
"cli": true,
|
||||
"mcp": false,
|
||||
"http": false
|
||||
},
|
||||
"toolset": "core",
|
||||
"stability": "preview",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inputs": {
|
||||
"type": "string",
|
||||
"format": "path",
|
||||
"description": "A verification document without its statement: contract, contracts, runs and ledgers.",
|
||||
"x-cli": {
|
||||
"positional": true
|
||||
}
|
||||
},
|
||||
"party": {
|
||||
"type": "string",
|
||||
"description": "The party's key, 64 hexadecimal digits: the operator or an owner.",
|
||||
"x-cli": {
|
||||
"flag": "--party"
|
||||
}
|
||||
},
|
||||
"start": {
|
||||
"type": "integer",
|
||||
"description": "The period's start, microseconds since 1970.",
|
||||
"x-cli": {
|
||||
"flag": "--start"
|
||||
}
|
||||
},
|
||||
"end": {
|
||||
"type": "integer",
|
||||
"description": "The period's end, after its start.",
|
||||
"x-cli": {
|
||||
"flag": "--end"
|
||||
}
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"format": "path",
|
||||
"description": "The operator key's file: 32 bytes, or 64 hexadecimal digits.",
|
||||
"x-cli": {
|
||||
"flag": "--key"
|
||||
}
|
||||
},
|
||||
"out": {
|
||||
"type": "string",
|
||||
"format": "path",
|
||||
"description": "The verification document's file (<digest>.wai-share.json).",
|
||||
"x-cli": {
|
||||
"flag": "--out"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"inputs",
|
||||
"party",
|
||||
"start",
|
||||
"end",
|
||||
"key"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"file",
|
||||
"statement",
|
||||
"outcome"
|
||||
],
|
||||
"description": "The statement (WRS1, hexadecimal), the file its verification document went to, and its outcome, recomputed here"
|
||||
},
|
||||
"stream": null,
|
||||
"since": "0.3.0",
|
||||
"deprecated": null,
|
||||
"receipt": true,
|
||||
"needs": {
|
||||
"all": [
|
||||
"revenue_share"
|
||||
]
|
||||
},
|
||||
"names": {
|
||||
"cli": "wai statement create",
|
||||
"mcp": null,
|
||||
"c": "wai_statement_create",
|
||||
"rust": "wai::statement::create",
|
||||
"sdk": "wai.statement.create"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "statement.verify",
|
||||
"summary": "A revenue-share statement's verification document checked: every line recomputed from the public records, or the reason it is refused",
|
||||
"cli": "wai statement verify",
|
||||
"aliases": [],
|
||||
"usage": [
|
||||
"wai statement verify <document.json> the outcome of a statement's verification document: each line, each split's",
|
||||
" division and each impression's attribution, recomputed; exit 1 with the reason on a refusal"
|
||||
],
|
||||
"placement": "local",
|
||||
"effect": "reads",
|
||||
"dry_run": false,
|
||||
"confirm": "never",
|
||||
"route": null,
|
||||
"doors": {
|
||||
"cli": true,
|
||||
"mcp": false,
|
||||
"http": false
|
||||
},
|
||||
"toolset": "core",
|
||||
"stability": "preview",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"document": {
|
||||
"type": "string",
|
||||
"format": "path",
|
||||
"description": "The verification document: contract, contracts, statement, runs and ledgers.",
|
||||
"x-cli": {
|
||||
"positional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"document"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"outcome",
|
||||
"statement",
|
||||
"contract",
|
||||
"party",
|
||||
"role",
|
||||
"lines",
|
||||
"total"
|
||||
],
|
||||
"description": "An accepted statement's outcome (revenue-share §8.3): its lines, each split's division, every impression attributed, and the total"
|
||||
},
|
||||
"stream": null,
|
||||
"since": "0.3.0",
|
||||
"deprecated": null,
|
||||
"receipt": false,
|
||||
"needs": {
|
||||
"all": [
|
||||
"revenue_share"
|
||||
]
|
||||
},
|
||||
"names": {
|
||||
"cli": "wai statement verify",
|
||||
"mcp": null,
|
||||
"c": "wai_statement_verify",
|
||||
"rust": "wai::statement::verify",
|
||||
"sdk": "wai.statement.verify"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ops.list",
|
||||
"summary": "Every operation of this catalogue: its id, command line, effect and whether this build holds it",
|
||||
|
|
|
|||
604
wai/extensions/revenue-share.md
Normal file
604
wai/extensions/revenue-share.md
Normal file
|
|
@ -0,0 +1,604 @@
|
|||
# WAI Extension: Revenue Share
|
||||
|
||||
> Status: Draft. Registers the record `wai.delivery.revenue_share`: what each content owner of a
|
||||
> linear channel is owed from the ads sold in the channel's breaks, as a statement anyone holding
|
||||
> the public records recomputes. An operator and every owner it names sign a **share contract**,
|
||||
> `WRC1`: its period, its channel and the key that signs the channel's program-as-run, the
|
||||
> carriers whose ad evidence belongs to the channel, the map from each asset to a **split** of
|
||||
> integer millionths among owners and the operator, and the rule for a break that falls between
|
||||
> programmes. The operator signs a **statement**, `WRS1`, for one party and one period: the
|
||||
> invoices of [receipt-ledger](receipt-ledger.md) it rests on, the program-as-run windows of
|
||||
> `wai.video.channel` it places impressions in, and one line per split and class, each with the
|
||||
> impressions attributed, the revenue the invoices' tariffs priced them at, the party's share of
|
||||
> it divided by the largest-remainder rule, and the records the line came from. A verifier given
|
||||
> the contract, the windows and the ledgers' own verification documents recomputes every line and
|
||||
> refuses a statement that does not recompute. No new transport, no payment and no currency
|
||||
> conversion: a statement is the recomputable figures a payment to an owner rests on. The
|
||||
> reference implementation is the `revenue_share` module of `wai-rs` (feature `revenue_share`);
|
||||
> the corpus is [`live-conformance/share/`](../live-conformance/share/), and its second
|
||||
> implementation is `live-conformance/revenue_share_verify.py`.
|
||||
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
|
||||
>
|
||||
> Texts read for this revision: the WAI texts this one cites; ISO 4217 (the alphabetic currency
|
||||
> code); RFC 3339 §5.6, for the date-time form of §5.4; RFC 8032; RFC 8259; FIPS 180-4.
|
||||
>
|
||||
> Why the name: the extension states how revenue is shared, and the record names both of its
|
||||
> parts, the contract and the statement, under one string, as `wai.delivery.ledger` names its
|
||||
> tariff and its invoice.
|
||||
|
||||
## 1. Scope
|
||||
|
||||
In scope: the share contract, its splits, its signatures and when two contracts overlap (§3); the
|
||||
program-as-run as a verifier is given it, the JSON carriage of a `wai.video.channel` receipt, whose
|
||||
signed bytes this text writes out and does not change (§4); the evidence, invoices of the receipt
|
||||
ledger and the impressions their sources state, with their instants (§5); attribution, from an
|
||||
impression's instant to an asset and its split (§6); the statement, its lines, rounding and
|
||||
citations (§7); verification (§8); how a statement composes with the ledger (§9); and the
|
||||
operations (§10).
|
||||
|
||||
Out of scope: a transport; paying an owner, an owner's account and any contact with a party
|
||||
outside the records; invoicing a buyer, which the receipt ledger does; converting a currency; how
|
||||
an operator chooses an owner's share; placing in time a presentation whose marker states no time
|
||||
this text reads (§5.4), which is kept in a class of its own; and the records themselves, which
|
||||
their own extensions define, verify and leave as they are. The channel receipt keeps its signed
|
||||
bytes: §4 writes them out for a verifier that is not `wai-rs`.
|
||||
|
||||
Relationship to the core spec: `wai.delivery.revenue_share` is a **record** ([SPEC](../SPEC.md)
|
||||
§5): a signed record a WAI tool emits and verifies. It names no payload format and is not
|
||||
dispatchable.
|
||||
|
||||
## 2. Terms
|
||||
|
||||
- **Operator**, **owner**: Ed25519 keys (RFC 8032). The operator carries the channel, sells its
|
||||
breaks and signs contracts and statements; an owner holds programmes the channel airs. A
|
||||
**party** of a contract is its operator or one of its owners.
|
||||
- **Time**: microseconds since 1970-01-01T00:00:00Z, the unit of receipt-ledger §2. A **period**
|
||||
is a span of time from its `start` (included) to its `end` (excluded). A channel receipt states
|
||||
milliseconds since the same instant; an instant of *m* milliseconds is *m* × 1000 microseconds,
|
||||
computed without overflow.
|
||||
- **Run**: one `wai.video.channel` receipt, a signed program-as-run window (§4). Its **window** is
|
||||
`[window_start_ms, window_end_ms)`, and an **entry** is one slot of its schedule.
|
||||
- **Programme**: an entry of kind `program`. An entry of kind `ad` or `filler` is not a programme.
|
||||
- **Carrier**: what delivers the channel's ads: a room of [live-ads](live-ads.md), named by its
|
||||
room id, whose break-form receipts the ledger bills; or a stitcher of
|
||||
[stitched-presentation](stitched-presentation.md), named by the key that signs its pod-input
|
||||
records.
|
||||
- **Ledger document**: a verification document of receipt-ledger §7.1, for one invoice.
|
||||
- **Impression**: one ad presented or billed, as the ledger counts it (§5.2).
|
||||
- **Minor unit**, **amount**: as receipt-ledger §2: an amount is an integer of millionths of a
|
||||
currency's minor unit.
|
||||
- **ppm**: an integer share in millionths, of 10^6.
|
||||
- *u64*, *u32*, *u16*, *u8*: little-endian unsigned integers of that many bits in the records of
|
||||
§3 and §7; the channel receipt's signed bytes (§4.2) are big-endian, as `wai.video.channel`
|
||||
writes them. A **digest** is SHA-256 (FIPS 180-4), 32 bytes; **zero** of a key or digest field
|
||||
is 32 zero bytes. A *text* field is a u16 length and that many bytes of UTF-8.
|
||||
|
||||
## 3. The share contract
|
||||
|
||||
### 3.1 Layout
|
||||
|
||||
| field | size | meaning |
|
||||
|---|---|---|
|
||||
| `magic` | 4 | `WRC1` (ASCII) |
|
||||
| `version` | 1 | 1 in this revision |
|
||||
| `operator` | 32 | the operator's key, which signs the contract and every statement under it |
|
||||
| `start`, `end` | 8 each | u64: the period the contract is in force |
|
||||
| `currency` | 3 | three capitals `A`–`Z` (ISO 4217): the currency of every invoice it shares |
|
||||
| `channel` | 2 + *n* | a text field of 1 to 1024 bytes: the `channel_id` of the channel's runs |
|
||||
| `channel_signer` | 32 | the key that signs the channel's runs |
|
||||
| `rule` | 1 | for an impression in an entry that is not a programme (§6.2): 1 **preceding**, 2 **following**, 3 **between** |
|
||||
| `between` | 2 | u16: the index of the **between split** (§6.2, §6.3) |
|
||||
| `carriers` | 2 + 33 × *c* | a u16 count, then each carrier: `kind` (u8: 1 a room, 2 a stitcher) and `id` (32: the room id, or the stitcher's key), in strictly ascending order of (`kind`, `id`) |
|
||||
| `owners` | 1 + 32 × *o* | a u8 count, then each owner's key, in strictly ascending order |
|
||||
| `splits` | 2 + … | a u16 count, then each split (§3.2) |
|
||||
| `assets` | 4 + … | a u32 count, then each asset: `asset` (a text field of 1 to 1024 bytes, an `asset_id` of the channel's schedule) and `split` (u16, the index of its split), in strictly ascending order of `asset`'s bytes |
|
||||
| `operator_sig` | 64 | §3.3 |
|
||||
| `countersigs` | 64 × *o* | each owner's countersignature, in the order of `owners` (§3.3) |
|
||||
|
||||
### 3.2 Splits
|
||||
|
||||
A split divides revenue among parties:
|
||||
|
||||
| field | size | meaning |
|
||||
|---|---|---|
|
||||
| `operator_ppm` | 4 | u32: the operator's share, 0 to 10^6 |
|
||||
| `m` | 1 | u8: the owners it names |
|
||||
| `shares` | 5 × *m* | each: `owner` (u8, the owner's index in `owners`) and `ppm` (u32, its share), in strictly ascending order of `owner` |
|
||||
|
||||
The **parties** of a split, in their **order**, are its owners in the order it lists them, then
|
||||
the operator when `operator_ppm` is above 0. A party **holds a share** of a split when it is one of
|
||||
its parties. The shares of a split sum to 10^6 exactly: a split is the whole of the revenue it is
|
||||
given, and no part of it is left unassigned.
|
||||
|
||||
### 3.3 Digest and signatures
|
||||
|
||||
The **body** of a contract is its bytes before `operator_sig`, and its **digest** is SHA-256 over
|
||||
the body. `operator_sig` is Ed25519 by `operator` over the bytes `wai:share-contract` (ASCII) and
|
||||
0x01, followed by the body. Each owner's countersignature is Ed25519 by that owner over the bytes
|
||||
`wai:share-accept` (ASCII) and 0x01, followed by the body; 64 zero bytes in its place state that
|
||||
the owner has not countersigned. An operator offers a contract with every countersignature zero,
|
||||
and each owner fills its own. Every signature is verified strictly, as [SPEC](../SPEC.md) §7
|
||||
*Signatures* requires: a key that fails step 1 or 2 is `bad_key`, and a signature that fails a
|
||||
later step is `bad_signature`.
|
||||
|
||||
A contract is **in force** only when every owner it names has countersigned it: the splits and
|
||||
the asset map bind each owner only by its own signature, and a contract some owner did not sign
|
||||
shares nothing.
|
||||
|
||||
### 3.4 Reading and rules
|
||||
|
||||
A reader reads exactly the bytes it is given as one contract, in the order of §3.1, and refuses at
|
||||
the first of these that holds: fewer than 4 bytes, `truncated`; a magic other than `WRC1`,
|
||||
`malformed`; no fifth byte, `truncated`; a version other than 1, `unknown_version`; the bytes end
|
||||
before a field the layout requires, `truncated`; bytes after the last countersignature,
|
||||
`malformed`. It then refuses as `contract_invalid` a contract that breaks any of these:
|
||||
|
||||
- `start` is below `end`; `currency` is three capitals `A`–`Z`;
|
||||
- `operator` and `channel_signer` are not zero, and `operator` is not an owner;
|
||||
- `channel` and every `asset` are UTF-8 of 1 to 1024 bytes;
|
||||
- `rule` is 1, 2 or 3, and `between` is the index of a split;
|
||||
- `carriers` has at least one entry, each `kind` 1 or 2 and each `id` not zero, in strictly
|
||||
ascending order;
|
||||
- `owners` has at least one key, none zero, in strictly ascending order;
|
||||
- `splits` has at least one split; in each, the owners are in strictly ascending order of index,
|
||||
each index below the count of `owners`, and each `ppm` above 0; and every owner holds a share of
|
||||
at least one split;
|
||||
- `assets` has at least one entry, in strictly ascending order, each `split` the index of a split.
|
||||
|
||||
It refuses as `split_not_whole` a contract with a split whose `operator_ppm` and `ppm`s do not sum
|
||||
to 10^6. Then it verifies `operator_sig` (`bad_key`, `bad_signature`), then each countersignature
|
||||
in turn: one of 64 zero bytes is `not_countersigned`, and one that does not verify is `bad_key` or
|
||||
`bad_signature`. A refusal of a signature names the operator, or the owner and its index.
|
||||
|
||||
### 3.5 Contracts that overlap
|
||||
|
||||
Two contracts **overlap** when their periods share an instant and they name the same `channel`, or
|
||||
a carrier in common. An asset aired under two contracts at once would have two owners, and a
|
||||
statement under either would share revenue the other also shares; a verifier holding both refuses
|
||||
a statement under either (§8.2 step 2, `contract_overlap`). An operator **MUST NOT** sign two
|
||||
contracts that overlap; one that changes a contract's terms signs a new contract whose period
|
||||
begins where the old one's ends.
|
||||
|
||||
## 4. The program-as-run
|
||||
|
||||
### 4.1 Its JSON carriage
|
||||
|
||||
A run is carried as one JSON object (RFC 8259) with exactly these members; a byte field is
|
||||
lowercase hexadecimal of its exact length, and an integer a JSON integer from 0 to 2^64 − 1:
|
||||
|
||||
| member | value |
|
||||
|---|---|
|
||||
| `channel_id` | a string: the channel |
|
||||
| `schedule_root` | 32 bytes: §4.2 |
|
||||
| `schedule` | an array of entries, each `{"asset_id", "start_ms", "duration_ms", "kind"}`: `asset_id` a string, `kind` one of `program`, `ad` and `filler` |
|
||||
| `window_start_ms`, `window_end_ms` | the window |
|
||||
| `aired` | an array of integers: the indices of the entries on air in the window |
|
||||
| `work_schedule_ops` | the entries' count, as the receipt states it |
|
||||
| `joules_micro` | the assembly's energy, or 0 unmetered |
|
||||
| `energy_provenance` | its class's label (energy-measurement §2.7: `ModelBased`, `Estimator`, `OnChipCounter`, `CalibratedInstrument`), or `null` unmetered |
|
||||
| `energy_uncertainty`, `energy_counter_filtering`, `energy_calibration_ref` | only for a class that carries them, as live-ads §8.2 writes them |
|
||||
| `grant` | `{"grant_hash", "capability", "joule_ceiling_micro", "funds_ceiling"}`, `funds_ceiling` `null` for none |
|
||||
| `parent_receipt_hash` | 32 bytes: the run before it in the channel's chain, or `null` |
|
||||
| `signer_pubkey` | 32 bytes: the channel signer's key |
|
||||
| `signer_id` | a string |
|
||||
| `sig` | 64 bytes: §4.2 |
|
||||
|
||||
A run **reads** when it is such an object and each entry's `start_ms` + `duration_ms` is at most
|
||||
2^64 − 1. The legacy class label `HwShunt` (energy-measurement §2.6) does not read in this
|
||||
carriage.
|
||||
|
||||
### 4.2 Its signed bytes and identity
|
||||
|
||||
These are the bytes `wai.video.channel` signs, written out; BLAKE3 throughout, integers
|
||||
big-endian:
|
||||
|
||||
- an entry's **leaf**: BLAKE3 over `wai:video-channel-entry` (ASCII) and 0x01, `asset_id`'s length
|
||||
(u32) and bytes, `start_ms` (u64), `duration_ms` (u64) and its kind's tag (u8: 1 `program`, 2
|
||||
`ad`, 3 `filler`);
|
||||
- the **schedule root**: the Merkle root over the leaves in schedule order, [jwp-receipts](jwp-receipts.md)
|
||||
§2.2's tree, each leaf taken as a content hash (its first leaf form) and the final one duplicated
|
||||
on an odd level; all zero for an empty schedule;
|
||||
- the **signing payload**: `wai:video-channel-receipt` (ASCII) and 0x01; `channel_id`'s length
|
||||
(u32) and bytes; `schedule_root`; `window_start_ms`, `window_end_ms`, `work_schedule_ops` and
|
||||
`joules_micro` (u64 each); the class in its signed encoding (energy-measurement §2.7), the tag
|
||||
byte 0 for none; the grant reference as live-ads §8.4 writes it (`grant_hash`, `capability`'s
|
||||
length (u32) and bytes, `joule_ceiling_micro` (u64), then 1 and `funds_ceiling` (u64), or 0);
|
||||
1 and `parent_receipt_hash`, or 0; the count of `aired` (u64) and each index (u64); the count of
|
||||
`schedule` (u64) and each entry's leaf; and `signer_pubkey`, then `signer_id`'s length (u32) and
|
||||
bytes;
|
||||
- `sig`: Ed25519 by `signer_pubkey` over the signing payload, verified strictly;
|
||||
- the run's **identity**, its receipt hash: BLAKE3 over `wai:video-channel-receipt-id` (ASCII) and
|
||||
0x01, the signing payload and `sig`.
|
||||
|
||||
### 4.3 When a run verifies
|
||||
|
||||
A run verifies when:
|
||||
|
||||
1. `schedule_root` is the schedule root of its entries, and `work_schedule_ops` their count;
|
||||
2. `window_start_ms` is below `window_end_ms`;
|
||||
3. the schedule is continuous: each entry starts where the one before it ends;
|
||||
4. `aired` is exactly the indices, ascending, of the entries that touch the window: those whose
|
||||
`start_ms` is below `window_end_ms` and whose `start_ms` + `duration_ms` is above
|
||||
`window_start_ms`;
|
||||
5. the figure and its class cohere as live-ads §8.5 rule 5 states it; and
|
||||
6. its signature verifies.
|
||||
|
||||
So the program-as-run is the schedule the channel signer signed, and what aired in a window is
|
||||
recomputed from it rather than read from a log.
|
||||
|
||||
## 5. The evidence
|
||||
|
||||
### 5.1 Invoices
|
||||
|
||||
A statement rests on **invoices** of the receipt ledger, each given as its ledger document
|
||||
(receipt-ledger §7.1): the tariff, the invoice, the invoice before it in its chain, and the
|
||||
sources. A ledger document is **accepted** when the receipt ledger's verification (§7.2 there)
|
||||
accepts it. Its tariff states the prices, its invoice the period and the records, and its sources
|
||||
the impressions. A statement prices nothing itself: an impression's revenue is the price the
|
||||
invoice billed it at.
|
||||
|
||||
Each invoice of a statement is one the operator sold as the seller of its tariff, in the
|
||||
contract's currency; the buyer is whoever bought the channel's breaks under that tariff. A channel
|
||||
whose breaks several buyers bought rests on several invoices, one chain per tariff.
|
||||
|
||||
### 5.2 Impressions and their classes
|
||||
|
||||
An accepted ledger document contributes, from its sources in order:
|
||||
|
||||
- for an `ads` source whose `room` is a carrier of kind 1: each receipt in the source's order, and
|
||||
each of its impressions in slot order that the ledger counts (receipt-ledger §6.3 and §8.1):
|
||||
- under a policy that requires presentation claims, an impression that counts, its own claim
|
||||
`presented` (ad-presentation §9.2): class **presented**, its records the receipt (kind 6) and
|
||||
its own claim (kind 7);
|
||||
- under a policy that does not, a billable impression (quartile at least 1): class **beacons**,
|
||||
its record the receipt (kind 6);
|
||||
- for a `stitched` source: each record the source accepts whose `signer` is a carrier of kind 2,
|
||||
each of its ads in slot order, and each presentation of the ad (stitched-presentation §7.4, a
|
||||
presented own claim) in the order its claims are given: class **presented** when its instant is
|
||||
stated (§5.4) and **unplaced** otherwise, its records the pod-input record (kind 8) and the
|
||||
claim (kind 9).
|
||||
|
||||
Record kinds are receipt-ledger §5.2's. Every other source, and every impression of a carrier the
|
||||
contract does not name, contributes nothing: an invoice may bill a buyer for several channels, and
|
||||
a statement shares the impressions of its own.
|
||||
|
||||
An impression's **price** is its invoice's tariff's `ads` price (receipt-ledger §4.1): per
|
||||
presented impression for the classes presented and unplaced, the unit `presented_impressions`
|
||||
bills; per billable impression for the class beacons, the unit `billable_impressions` bills. So an
|
||||
impression is priced at the unit the invoice billed it under, and the classes keep apart what the
|
||||
players' own claims show presented and what rests on a decider's beacons alone. A class is never
|
||||
summed into another: each has its own lines (§7.2).
|
||||
|
||||
### 5.3 Its instant
|
||||
|
||||
An impression's **instant** places it on the channel's timeline:
|
||||
|
||||
- an impression of an `ads` source: its room's `t0` plus the `t` of the break its receipt names
|
||||
(live-ads §4.1, placed as receipt-ledger §5.3 places it). Every impression of a break shares the
|
||||
break's instant: a break is attributed whole;
|
||||
- a presentation of a stitched record: the instant its marker states (§5.4). A presentation whose
|
||||
marker states none has no instant: it is **unplaced**.
|
||||
|
||||
### 5.4 The instant a marker states
|
||||
|
||||
A marker of carriage 1 or 2 (stitched-presentation §3.1) carries the `START-DATE` of its date
|
||||
range, the second long field of its body. It **states** an instant when it is exactly this form, in
|
||||
ASCII, of RFC 3339 §5.6's `date-time` with its letters in capitals:
|
||||
|
||||
```
|
||||
YYYY "-" MM "-" DD "T" hh ":" mm ":" ss [ "." 1 to 9 digits ] ( "Z" / ( "+" / "-" ) hh ":" mm )
|
||||
```
|
||||
|
||||
with `YYYY` a year of four digits, `MM` 01 to 12, `DD` 01 to the days of that month in the
|
||||
Gregorian calendar, `hh` 00 to 23, `mm` 00 to 59 and `ss` 00 to 59 (an offset's `hh` and `mm` the
|
||||
same), and the instant it names, in microseconds, at or after 1970-01-01T00:00:00Z. The instant is
|
||||
the date and time less the offset, its fraction truncated to the microsecond. A marker of carriage
|
||||
3 carries no date (§3.4 there): its presentations are unplaced, as are those of a `START-DATE` that
|
||||
does not state an instant.
|
||||
|
||||
## 6. Attribution
|
||||
|
||||
### 6.1 The run of an instant
|
||||
|
||||
The runs a verifier is given are of the contract's channel and signer and do not overlap (§8.2
|
||||
step 4). The **run** of an instant is the run whose window holds it; the **entry** of the instant
|
||||
is the entry of that run's schedule whose span holds it. An impression with an instant and no run,
|
||||
or a run and no entry (an instant past its schedule's end), aired in no window the verifier holds:
|
||||
it is refused `outside_window`.
|
||||
|
||||
### 6.2 The asset and its split
|
||||
|
||||
The entry of an impression's instant gives its **asset**:
|
||||
|
||||
- a programme gives its own `asset_id`: a break inside a programme is that programme's;
|
||||
- an entry that is not a programme gives, by the contract's `rule`: for **preceding**, the
|
||||
`asset_id` of the last programme before it in the run's schedule; for **following**, that of the
|
||||
first programme after it; when there is no such programme, or for **between**, no asset.
|
||||
|
||||
An impression with an asset is attributed to the asset's split in the contract's `assets`; an asset
|
||||
the contract does not map is refused `asset_unmapped`. One with no asset is attributed to the
|
||||
between split. The rule applies to the run's own signed schedule, so anyone holding the run finds
|
||||
the same programme.
|
||||
|
||||
### 6.3 Unplaced impressions
|
||||
|
||||
An unplaced impression is attributed to the between split, in the class unplaced. It is revenue
|
||||
the channel earned in some break that no record places in a programme, and the contract's between
|
||||
split is how its parties agreed to share such revenue. It is never counted in the class presented:
|
||||
a statement states it apart, and an owner sees how much of its revenue no record placed.
|
||||
|
||||
## 7. The statement
|
||||
|
||||
### 7.1 Layout
|
||||
|
||||
| field | size | meaning |
|
||||
|---|---|---|
|
||||
| `magic` | 4 | `WRS1` (ASCII) |
|
||||
| `version` | 1 | 1 in this revision |
|
||||
| `contract` | 32 | the contract's digest (§3.3) |
|
||||
| `party` | 32 | the party it is for: the operator or an owner |
|
||||
| `start`, `end` | 8 each | u64: the period it states |
|
||||
| `currency` | 3 | the contract's |
|
||||
| `invoices` | 2 + 32 × *i* | a u16 count, then each invoice's digest (receipt-ledger §6.7), in strictly ascending order |
|
||||
| `runs` | 2 + 32 × *r* | a u16 count, then each run's identity (§4.2), in strictly ascending order |
|
||||
| `lines` | 2 + 67 × *l* | a u16 count, then each line (§7.2), in strictly ascending order of (`split`, `class`) |
|
||||
| `total` | 8 | u64: the sum of the lines' amounts |
|
||||
| `sig` | 64 | §7.5 |
|
||||
|
||||
### 7.2 Lines
|
||||
|
||||
A line is 67 bytes:
|
||||
|
||||
| field | size | meaning |
|
||||
|---|---|---|
|
||||
| `split` | 2 | u16: the split's index |
|
||||
| `class` | 1 | 1 presented, 2 beacons, 3 unplaced (§5.2) |
|
||||
| `impressions` | 8 | u64: the impressions of the class attributed to the split |
|
||||
| `revenue` | 8 | u64: the sum of their prices |
|
||||
| `ppm` | 4 | u32: the party's share of the split |
|
||||
| `amount` | 8 | u64: the party's part of the revenue (§7.3) |
|
||||
| `cited` | 4 | u32: the records the line cites (§7.4) |
|
||||
| `cites` | 32 | their citation digest (§7.4) |
|
||||
|
||||
There is one line for each split of which the party holds a share and each class with at least one
|
||||
impression attributed to that split, and none other. A party's statement so states the whole
|
||||
revenue of every split it shares in, and its part of each.
|
||||
|
||||
### 7.3 Revenue, shares and rounding
|
||||
|
||||
A line's revenue is divided among the parties of its split by the **largest-remainder rule**, so
|
||||
that the parts are integers and sum to the revenue exactly:
|
||||
|
||||
1. For each party *i* of the split, in its order (§3.2), its **quota** is `revenue` × `ppm`*ᵢ*,
|
||||
computed in integers wide enough not to overflow; its **base** is ⌊quota / 10^6⌋, and its
|
||||
**remainder** is quota − base × 10^6.
|
||||
2. The **leftover** is `revenue` less the sum of the bases. It is less than the count of parties.
|
||||
3. The parties with the largest remainders, as many as the leftover, each receive one more than
|
||||
their base; between equal remainders, the earlier party in the order receives it first.
|
||||
4. A party's **part** is its base, plus one when step 3 gives it one.
|
||||
|
||||
The parts of a split's parties sum to its revenue, whoever's statement states them: the operator's
|
||||
statement and each owner's state the same revenue for a split and class, and their parts sum to
|
||||
it. Rounding is applied once per split and class over the statement's period, never per
|
||||
impression, so it does not accumulate.
|
||||
|
||||
### 7.4 Citations
|
||||
|
||||
Each line cites the records it came from: for each impression attributed to its split in its class,
|
||||
the impression's records (§5.2), the run that placed it (kind 10, its identity) when it has an
|
||||
instant, and the invoice that billed it (kind 11, its digest). `cited` is their number, each record
|
||||
counted once, and `cites` is SHA-256 over the bytes `wai:share-cites` (ASCII) and 0x01, followed by
|
||||
each cited record's `kind` (u8) and identity (32), in ascending order of (`kind`, identity). A line
|
||||
that cites no record has no source (`line_unsourced`, §8.2 step 7). An owner shows one line to an
|
||||
auditor with the records it cites, the contract and the runs.
|
||||
|
||||
### 7.5 Digest and signature
|
||||
|
||||
The **digest** of a statement is SHA-256 over its bytes before `sig`. Its signature is Ed25519 by
|
||||
the contract's `operator` over the bytes `wai:share-statement` (ASCII) and 0x01, followed by the
|
||||
bytes the digest covers, verified strictly (SPEC §7 *Signatures*).
|
||||
|
||||
### 7.6 Reading and rules
|
||||
|
||||
A reader reads exactly the bytes it is given as one statement, as §3.4 reads a contract with `WRS1`
|
||||
for `WRC1` (`truncated`, `malformed`, `unknown_version`), and refuses as
|
||||
`share_statement_invalid` one that breaks any of these:
|
||||
|
||||
- `start` is below `end`; `currency` is three capitals `A`–`Z`; `party` is not zero;
|
||||
- `invoices`, `runs` and `lines` are each in the strictly ascending order §7.1 gives it;
|
||||
- each line's `class` is 1, 2 or 3, its `impressions` and `cited` above 0, its `ppm` 1 to 10^6, and
|
||||
its `amount` at most its `revenue`.
|
||||
|
||||
Its signature is checked against the contract's operator (§8.2 step 3).
|
||||
|
||||
## 8. Verification
|
||||
|
||||
### 8.1 Inputs
|
||||
|
||||
A verifier is given the **contract**; the **other contracts** it holds of the operator or of the
|
||||
channel, for the overlap check; the **statement**; the **runs**; and the **ledger documents**. Its
|
||||
verification document holds them as one JSON object with exactly the members `contract` and
|
||||
`statement` (hexadecimal), `contracts` (a list of hexadecimal strings), `runs` (a list of runs in
|
||||
their carriage, §4.1) and `ledgers` (a list of ledger documents, receipt-ledger §7.1). A document
|
||||
that is not such an object is refused `malformed`.
|
||||
|
||||
### 8.2 Steps
|
||||
|
||||
It runs these steps and stops at the first refusal, which it reports with the reason and where:
|
||||
|
||||
1. **The contract** (§3.4), at `contract`; a signature's refusal at `contract` and `operator`, or
|
||||
`contract`, `owner` and the owner's index: `truncated`, `malformed`, `unknown_version`,
|
||||
`contract_invalid`, `split_not_whole`, `bad_key`, `bad_signature`, `not_countersigned`.
|
||||
2. **The other contracts**, each at `contracts` and its index, a signature's place after it: each
|
||||
is read and verified as step 1, with those reasons; one whose digest is the contract's is the
|
||||
contract and is passed over; one that overlaps the contract (§3.5) is `contract_overlap`.
|
||||
3. **The statement**, at `statement`: it reads (§7.6: `truncated`, `malformed`, `unknown_version`,
|
||||
`share_statement_invalid`); its `contract` is the contract's digest, its `party` the operator
|
||||
or an owner of the contract, and its `currency` the contract's: otherwise `contract_mismatch`;
|
||||
its period lies within the contract's: otherwise `outside_contract`; its signature verifies
|
||||
under the operator (§7.5): `bad_key`, `bad_signature`.
|
||||
4. **The runs**, each at `run` and its index: it reads (§4.1: `malformed`); it verifies (§4.3:
|
||||
`run_invalid`); its `channel_id` is the contract's `channel` and its `signer_pubkey` the
|
||||
`channel_signer`: otherwise `run_foreign`; its window shares an instant with the window of a
|
||||
run before it: `run_overlap`.
|
||||
5. **The ledger documents**, each at `ledger` and its index: it is verified as receipt-ledger §7.2
|
||||
verifies one, a refusal being that extension's reason with its place after the index; its
|
||||
invoice's `seller` is the operator and its `currency` the contract's: otherwise
|
||||
`invoice_foreign`; its period lies within the statement's: otherwise `outside_period`; and no
|
||||
record of kinds 6 to 9 in its invoice's `records` is in the `records` of an invoice before it,
|
||||
and no ad receipt of its sources names the room, the cue and the session an ad receipt of a
|
||||
document before it names: otherwise `impression_duplicate`. A ledger refuses a record billed
|
||||
twice within one invoice and one chain (receipt-ledger §5.2, §6.9); this step refuses one billed
|
||||
by two invoices of a statement, under two tariffs among them.
|
||||
6. **The impressions** (§5.2), in the order the documents give them, each at `ledger`, its index,
|
||||
`source` and the source's index: an impression with an instant (§5.3) whose instant does not lie
|
||||
within the statement's period is `outside_period`; one that aired in no window (§6.1) is
|
||||
`outside_window`; one whose asset the contract does not map (§6.2) is `asset_unmapped`.
|
||||
7. **The figures**, at `statement`, recomputed from the impressions as §7.2 to §7.4 compute them for
|
||||
the statement's party, and compared in this order:
|
||||
1. a line whose split and class give the party no impression, or that states more impressions
|
||||
than they give: `line_unsourced`, a line no signed record covers;
|
||||
2. a line whose `cited` or `cites` is not the records it came from: `citation_mismatch`;
|
||||
3. any other difference (the invoices, the runs, a line missing, fewer impressions, a revenue, a
|
||||
`ppm`, an amount, the `total`): `share_statement_mismatch`.
|
||||
|
||||
A figure that cannot be held in its field is a difference.
|
||||
|
||||
### 8.3 Outcome
|
||||
|
||||
- **refuse**: the reason and where.
|
||||
- **accept**: the statement's digest; the contract's digest; the party and whether it is the
|
||||
operator or an owner; the currency and the period; the invoices and the runs; each impression of
|
||||
the contract's carriers, in the order of step 6, with the ledger document and source it came
|
||||
from, its records, its class and price, its instant, run, entry and asset (each `null` when it has
|
||||
none) and its split; each line with its split, class name, impressions, revenue, the party's
|
||||
`ppm` and amount, its cited records, and the **division**, every party of its split with its
|
||||
`ppm` and part; and the total.
|
||||
|
||||
The division is not signed: anyone holding the records computes it, and it shows that the parts of
|
||||
a split sum to its revenue.
|
||||
|
||||
## 9. The statement and the receipt ledger
|
||||
|
||||
A statement composes with the ledger and duplicates none of it:
|
||||
|
||||
- **Its sources are the ledger's.** The impressions a statement shares are those of the `ads` and
|
||||
`stitched` sources of invoices the ledger accepted, verified as the ledger verifies them: by
|
||||
live-ads with ad-presentation, and by stitched-presentation. A statement recomputes no decision,
|
||||
no claim and no presentation; it reads the ledger's counts and verdicts.
|
||||
- **Its prices are the tariff's.** An impression's revenue is its invoice's price for the unit that
|
||||
billed it, so an owner's revenue is a part of what the buyer was invoiced, never a figure the
|
||||
operator states.
|
||||
- **Its records are the invoice's.** Each impression is named by the records the invoice holds,
|
||||
and the statement cites the invoice itself. A statement adds the two things the ledger does not
|
||||
hold: the instant of each impression on the channel's program-as-run, and the split the contract
|
||||
gives the programme that aired at that instant.
|
||||
- **An operator that bills a channel's breaks** puts the sources of the channel's carriers into the
|
||||
ledger as the ledger receives any source (receipt-ledger §5.1, §9), under a tariff whose scope
|
||||
names the carriers' deciders and stitchers (receipt-ledger §4.4). The verification document the
|
||||
ledger keeps with each invoice (receipt-ledger §9: `GET /v1/invoices/{digest}`) is the ledger
|
||||
document a statement is given, unchanged.
|
||||
|
||||
Paying an owner its part, and an owner invoicing the operator for it, are not sources the ledger
|
||||
defines, and this revision defines none.
|
||||
|
||||
## 10. Operations
|
||||
|
||||
`wai` runs the operations of this extension, under the agent rules of the operation catalogue (the
|
||||
`--json` envelope, the exit codes, `--dry-run` on every operation that writes, `--yes` before a file
|
||||
is replaced). A refusal by this text has its reason as the operation's code.
|
||||
|
||||
| operation | command line | does |
|
||||
|---|---|---|
|
||||
| `contract.create` | `wai contract create <terms.json> --key <file> [--out F]` | the operator's contract from its terms, signed by the operator with every countersignature zero (§3.3) |
|
||||
| `contract.update` | `wai contract accept <contract> --key <file> [--out F]` | an owner's countersignature added, after the contract's rules and the operator's signature are checked |
|
||||
| `statement.create` | `wai statement create <inputs.json> --party <key> --start µs --end µs --key <file> [--out F]` | the statement of a party and a period, computed from a verification document without its statement (§8.1), signed by the operator, written with its document |
|
||||
| `statement.verify` | `wai statement verify <document.json>` | a verification document's outcome (§8.3); exit 1 with the reason on a refusal |
|
||||
|
||||
## 11. Reasons
|
||||
|
||||
The codes this extension adds, each with where it is raised:
|
||||
|
||||
| reason | raised at |
|
||||
|---|---|
|
||||
| `contract_invalid` | §3.4, §8.2 steps 1 and 2 |
|
||||
| `split_not_whole` | §3.2, §3.4, §8.2 steps 1 and 2 |
|
||||
| `not_countersigned` | §3.3, §8.2 steps 1 and 2 |
|
||||
| `contract_overlap` | §3.5, §8.2 step 2 |
|
||||
| `share_statement_invalid` | §7.6, §8.2 step 3 |
|
||||
| `contract_mismatch` | §8.2 step 3 |
|
||||
| `outside_contract` | §8.2 step 3 |
|
||||
| `run_invalid` | §4.3, §8.2 step 4 |
|
||||
| `run_foreign` | §8.2 step 4 |
|
||||
| `run_overlap` | §6.1, §8.2 step 4 |
|
||||
| `invoice_foreign` | §5.1, §8.2 step 5 |
|
||||
| `impression_duplicate` | §8.2 step 5 |
|
||||
| `outside_window` | §6.1, §8.2 step 6 |
|
||||
| `asset_unmapped` | §6.2, §8.2 step 6 |
|
||||
| `share_statement_mismatch` | §8.2 step 7 |
|
||||
|
||||
A verifier of this extension also raises, with the meaning this text gives them: `truncated`,
|
||||
`malformed` and `unknown_version` for a contract or a statement that does not read, and `malformed`
|
||||
for a document or a run that does not read; `bad_key` and `bad_signature` for their signatures;
|
||||
`outside_period` (receipt-ledger §10) for an invoice or an impression outside the statement's
|
||||
period; `line_unsourced` (fleet §10) for a line no impression gives; `citation_mismatch`
|
||||
(receipt-ledger §10) for a line's citations; and, at a ledger document, every code the receipt
|
||||
ledger raises (receipt-ledger §10 and the codes it lists).
|
||||
|
||||
## 12. Conformance
|
||||
|
||||
[`live-conformance/share/`](../live-conformance/share/) holds the corpus, in one file,
|
||||
`vectors.json`, written by `wai_revenue_share_vectors gen` from fixed seeds and from the sources of
|
||||
the ledger corpus (`live-conformance/ledger/`): a room's break-form receipts under a policy that
|
||||
requires claims and under one that does not, and three streams stitched by others, one of whose
|
||||
markers are DASH events. The ledger documents, the runs and the contracts are held once, by name,
|
||||
and each vector is a verification document (§8.1) naming them, with the outcome §8.3 gives it:
|
||||
|
||||
- **Accepted**: a channel's month, its two windows airing two owners' programmes with mid-roll
|
||||
breaks and an ad slot between programmes, its impressions from two invoices of one chain, one of
|
||||
the room's breaks and one of the stitched streams; the statement of each owner and of the
|
||||
operator under the rule preceding, so that the three statements' parts of each split sum to its
|
||||
revenue; and an owner's statement under the rules following and between.
|
||||
- **Refused**: each reason of §11 at least once, each vector a control of one defect; each code §11
|
||||
reuses; and a ledger document refused by the receipt ledger, at its place.
|
||||
|
||||
`wai_revenue_share_vectors verify` and the test `tests/revenue_share.rs` check that the file
|
||||
regenerates byte for byte, that `wai-rs` gives every vector its outcome, that every reason of §11
|
||||
refuses a vector, and that §11 lists exactly the reference's codes; the test also holds the worked
|
||||
example, three statements of one month computed and then recomputed from the public records alone,
|
||||
and the module's unit tests the rounding rule's ties. `live-conformance/revenue_share_verify.py` is
|
||||
the second implementation, written from this text: it shares no code with `wai-rs`, verifies
|
||||
signatures with WAI's strict rule written from RFC 8032 (`claim-conformance/_ed25519.py`) and BLAKE3
|
||||
with `claim-conformance/_blake3.py`, verifies each ledger document with that corpus's own second
|
||||
implementation (`receipt_ledger_verify.py`), reads every contract, run, marker and statement itself,
|
||||
recomputes every attribution, line, division and citation, gives every vector the same outcome,
|
||||
requires every mutated copy of the corpus to fail, and drops each of its own checks in turn and
|
||||
requires the corpus to catch the weakened verifier.
|
||||
|
||||
## 13. Security and privacy considerations
|
||||
|
||||
- **Who signs what.** Each owner signs the splits and the asset map it is bound by; the channel
|
||||
signer signs what aired; the decider, the stitcher and each player sign the impressions; the
|
||||
buyer signs the prices; the operator signs the statement. An operator cannot move an
|
||||
impression to another programme, price it at another price or round a part against an owner: the
|
||||
verifier recomputes every line from records the operator did not sign alone.
|
||||
- **What the operator still controls.** The records show what was invoiced from the contract's
|
||||
carriers. An operator that sells a break without invoicing it, or carries the channel through a
|
||||
room or a stitcher the contract does not name, keeps that revenue out of every statement; the
|
||||
contract's carriers are what the owners agreed to, and an owner that suspects more carriers asks
|
||||
for them by name.
|
||||
- **The between split.** Revenue in an entry that is not a programme, and every unplaced
|
||||
presentation, goes to the between split. An owner that agrees a between split in which it holds
|
||||
no share agrees to receive nothing from such breaks, and a statement states their revenue apart
|
||||
so that the owner sees it.
|
||||
- **Double counting.** One invoice and one chain bill a record once (the ledger's rules); two
|
||||
invoices of one statement may not bill one impression (`impression_duplicate`); two contracts
|
||||
may not share one channel or carrier at once (`contract_overlap`); and two runs may not place one
|
||||
instant (`run_overlap`). A verifier holding fewer contracts than the operator signed cannot see
|
||||
an overlap with the ones it lacks.
|
||||
- **What a statement reveals.** A statement names the invoices and runs it rests on, and a line
|
||||
states its split's whole revenue: the co-owners of a split, and the operator, see the same
|
||||
figures. A verification document holds the ledgers' sources, whose receipts name sessions and
|
||||
whose claims name session keys made for one session alone (ad-presentation §3,
|
||||
stitched-presentation §5.2). It travels between the operator, the owner and an auditor they
|
||||
choose.
|
||||
893
wai/live-conformance/revenue_share_verify.py
Normal file
893
wai/live-conformance/revenue_share_verify.py
Normal file
|
|
@ -0,0 +1,893 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Independent verifier of the revenue-share corpus (extensions/revenue-share.md).
|
||||
|
||||
Written from the extension's text. It shares no code with wai-rs, which generates the corpus,
|
||||
and never runs it. It checks Ed25519 signatures with ../claim-conformance/_ed25519.py, WAI's
|
||||
strict rule (SPEC §7 *Signatures*) written from RFC 8032 with the standard library, and BLAKE3
|
||||
with ../claim-conformance/_blake3.py. Each ledger document is verified by the ledger corpus's
|
||||
own second implementation, receipt_ledger_verify.py (receipt-ledger §7), which states what each
|
||||
source contributes. Everything else is here:
|
||||
|
||||
- the share contract (§3): reading, rules, splits, the operator's signature and each
|
||||
countersignature, and when two contracts overlap;
|
||||
- the program-as-run (§4): its JSON carriage, its signed bytes, its identity and when it
|
||||
verifies;
|
||||
- the impressions of the contract's carriers (§5.2), their prices, their instants (§5.3) and the
|
||||
date-time form a marker states (§5.4);
|
||||
- attribution (§6), the statement (§7): its reading, its lines, the largest-remainder division
|
||||
and the citations; verification (§8), compared in §8.2's order.
|
||||
|
||||
For every vector it requires the outcome the corpus states; of the corpus, that every reason of
|
||||
§11 and every code it reuses appears in it and that no other file is there. Then it mutates copies
|
||||
of the corpus, one defect each, and requires every copy to fail; and it drops each of its own
|
||||
checks in turn and requires the corpus to catch the weakened verifier.
|
||||
|
||||
python3 -I revenue_share_verify.py <live-conformance/share>
|
||||
python3 -I revenue_share_verify.py --document <verification document>
|
||||
|
||||
Exit status 0 only if every vector verifies, every mutated copy fails and every weakened verifier
|
||||
fails. Python 3.8 or later; standard library only.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import datetime
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
|
||||
import _blake3 as B3 # noqa: E402
|
||||
import _ed25519 as ED # noqa: E402
|
||||
import receipt_ledger_verify as RLV # noqa: E402
|
||||
|
||||
EXTENSION = os.path.join(HERE, "..", "extensions", "revenue-share.md")
|
||||
|
||||
CONTRACT_MAGIC, STATEMENT_MAGIC = b"WRC1", b"WRS1" # §3.1, §7.1
|
||||
CONTRACT_DOMAIN = b"wai:share-contract\x01" # §3.3
|
||||
ACCEPT_DOMAIN = b"wai:share-accept\x01" # §3.3
|
||||
STATEMENT_DOMAIN = b"wai:share-statement\x01" # §7.5
|
||||
CITES_DOMAIN = b"wai:share-cites\x01" # §7.4
|
||||
ENTRY_DOMAIN = b"wai:video-channel-entry\x01" # §4.2
|
||||
RUN_DOMAIN = b"wai:video-channel-receipt\x01" # §4.2
|
||||
RUN_ID_DOMAIN = b"wai:video-channel-receipt-id\x01" # §4.2
|
||||
LEAF, NODE = b"moq-jwp:merkle-leaf\x01", b"moq-jwp:merkle-node\x01" # jwp-receipts §2.2
|
||||
WHOLE = 10 ** 6
|
||||
U64, U32 = 2 ** 64 - 1, 2 ** 32 - 1
|
||||
ZERO = bytes(32)
|
||||
ROOM, STITCHER = 1, 2 # §3.1 carriers
|
||||
PRECEDING, FOLLOWING, BETWEEN = 1, 2, 3 # §3.1 rule
|
||||
PRESENTED, BEACONS, UNPLACED = 1, 2, 3 # §5.2 classes
|
||||
CLASS_NAMES = {PRESENTED: "presented", BEACONS: "beacons", UNPLACED: "unplaced"}
|
||||
AD_RECEIPT, CLAIM, POD_INPUT, STITCHED_CLAIM, RUN, INVOICE = 6, 7, 8, 9, 10, 11 # §7.4
|
||||
KINDS = {"program": 1, "ad": 2, "filler": 3} # §4.2 entry kind tags
|
||||
CLASS_TAG = {"ModelBased": 2, "Estimator": 3, "OnChipCounter": 4, "CalibratedInstrument": 5} # energy-measurement §2.7
|
||||
EVIDENCE = {"ModelBased": [], "Estimator": [], "OnChipCounter": ["energy_uncertainty", "energy_counter_filtering"],
|
||||
"CalibratedInstrument": ["energy_uncertainty", "energy_calibration_ref"]}
|
||||
FILTERING = {"Unknown": 0, "Off": 1, "On": 2}
|
||||
FLOOR = 288676 # live-ads §8.5 rule 5
|
||||
WHITE_SPACE = set(map(chr, [0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20, 0x85, 0xA0, 0x1680] + list(range(0x2000, 0x200B))
|
||||
+ [0x2028, 0x2029, 0x202F, 0x205F, 0x3000]))
|
||||
RUN_MEMBERS = ["channel_id", "schedule_root", "schedule", "window_start_ms", "window_end_ms", "aired", "work_schedule_ops",
|
||||
"joules_micro", "energy_provenance", "grant", "parent_receipt_hash", "signer_pubkey", "signer_id", "sig"]
|
||||
REUSED = ["truncated", "malformed", "unknown_version", "bad_key", "bad_signature", "outside_period", "line_unsourced",
|
||||
"citation_mismatch"]
|
||||
DATE = re.compile(rb"(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})") # §5.4
|
||||
EPOCH = datetime.date(1970, 1, 1).toordinal()
|
||||
|
||||
SKIP = set()
|
||||
|
||||
|
||||
class Refuse(Exception):
|
||||
def __init__(self, reason, at):
|
||||
super().__init__(reason)
|
||||
self.reason, self.at = reason, at
|
||||
|
||||
|
||||
class NoRead(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def sha(*parts):
|
||||
h = hashlib.sha256()
|
||||
for p in parts:
|
||||
h.update(p)
|
||||
return h.digest()
|
||||
|
||||
|
||||
def b3(*parts):
|
||||
return B3.blake3(b"".join(parts))
|
||||
|
||||
|
||||
def be64(v):
|
||||
return struct.pack(">Q", v)
|
||||
|
||||
|
||||
def be32(v):
|
||||
return struct.pack(">I", v)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def ed_verify(key, msg, sig):
|
||||
"""WAI's strict rule: None, `bad_key` or `bad_signature`. A pure function, held once."""
|
||||
return ED.verify(key, msg, sig)
|
||||
|
||||
|
||||
class R:
|
||||
"""A cursor over little-endian fields; running out is `truncated`."""
|
||||
|
||||
def __init__(self, b, p=0):
|
||||
self.b, self.p = b, p
|
||||
|
||||
def take(self, n):
|
||||
if self.p + n > len(self.b):
|
||||
raise ValueError("truncated")
|
||||
s = self.b[self.p:self.p + n]
|
||||
self.p += n
|
||||
return s
|
||||
|
||||
def u(self, n):
|
||||
return int.from_bytes(self.take(n), "little")
|
||||
|
||||
def text(self):
|
||||
return self.take(self.u(2))
|
||||
|
||||
|
||||
def head(b, magic):
|
||||
"""§3.4's first steps."""
|
||||
if len(b) < 4:
|
||||
return "truncated"
|
||||
if b[:4] != magic:
|
||||
return "malformed"
|
||||
if len(b) < 5:
|
||||
return "truncated"
|
||||
if b[4] != 1:
|
||||
return "unknown_version"
|
||||
return None
|
||||
|
||||
|
||||
def capitals(c):
|
||||
return len(c) == 3 and all(0x41 <= x <= 0x5A for x in c)
|
||||
|
||||
|
||||
def ascending(xs):
|
||||
return all(a < b for a, b in zip(xs, xs[1:]))
|
||||
|
||||
|
||||
def good_text(t):
|
||||
"""UTF-8 of 1 to 1024 bytes (§3.4)."""
|
||||
if not 1 <= len(t) <= 1024:
|
||||
return False
|
||||
try:
|
||||
t.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def unhex(v):
|
||||
if not isinstance(v, str) or len(v) % 2 or any(c not in "0123456789abcdef" for c in v):
|
||||
raise Refuse("malformed", [])
|
||||
return bytes.fromhex(v)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §3 the contract
|
||||
|
||||
def read_contract(b):
|
||||
"""§3.4: the contract held to its rules and its splits' sums, or Refuse (place relative)."""
|
||||
bad = head(b, CONTRACT_MAGIC)
|
||||
if bad:
|
||||
raise Refuse(bad, [])
|
||||
r = R(b, 5)
|
||||
try:
|
||||
c = {"operator": r.take(32), "start": r.u(8), "end": r.u(8), "currency": r.take(3), "channel": r.text(),
|
||||
"channel_signer": r.take(32), "rule": r.u(1), "between": r.u(2)}
|
||||
c["carriers"] = [(r.u(1), r.take(32)) for _ in range(r.u(2))]
|
||||
c["owners"] = [r.take(32) for _ in range(r.u(1))]
|
||||
c["splits"] = []
|
||||
for _ in range(r.u(2)):
|
||||
op = r.u(4)
|
||||
c["splits"].append({"operator_ppm": op, "shares": [(r.u(1), r.u(4)) for _ in range(r.u(1))]})
|
||||
c["assets"] = [(r.text(), r.u(2)) for _ in range(r.u(4))]
|
||||
body_end = r.p
|
||||
c["operator_sig"] = r.take(64)
|
||||
c["countersigs"] = [r.take(64) for _ in c["owners"]]
|
||||
except ValueError:
|
||||
raise Refuse("truncated", [])
|
||||
if r.p != len(b):
|
||||
raise Refuse("malformed", [])
|
||||
n = len(c["owners"])
|
||||
rules = (c["start"] < c["end"] and capitals(c["currency"]) and c["operator"] != ZERO and c["channel_signer"] != ZERO
|
||||
and c["operator"] not in c["owners"] and good_text(c["channel"]) and 1 <= c["rule"] <= 3
|
||||
and c["between"] < len(c["splits"])
|
||||
and c["carriers"] and ascending(c["carriers"]) and all(k in (ROOM, STITCHER) and i != ZERO for k, i in c["carriers"])
|
||||
and n >= 1 and ascending(c["owners"]) and all(o != ZERO for o in c["owners"])
|
||||
and c["splits"]
|
||||
and all(ascending([i for i, _ in s["shares"]]) and all(i < n and p > 0 for i, p in s["shares"]) for s in c["splits"])
|
||||
and all(any(i == o for s in c["splits"] for i, _ in s["shares"]) for o in range(n))
|
||||
and c["assets"] and ascending([a for a, _ in c["assets"]])
|
||||
and all(good_text(a) and s < len(c["splits"]) for a, s in c["assets"]))
|
||||
if not rules and "contract-rules" not in SKIP:
|
||||
raise Refuse("contract_invalid", [])
|
||||
if any(s["operator_ppm"] + sum(p for _, p in s["shares"]) != WHOLE for s in c["splits"]) and "split-sums" not in SKIP:
|
||||
raise Refuse("split_not_whole", [])
|
||||
c["body"] = b[:body_end]
|
||||
c["digest"] = sha(c["body"])
|
||||
return c
|
||||
|
||||
|
||||
def in_force(b):
|
||||
"""§3.3, §3.4: read, then the operator's signature, then each countersignature in turn."""
|
||||
c = read_contract(b)
|
||||
if "operator-signature" not in SKIP:
|
||||
bad = ed_verify(c["operator"], CONTRACT_DOMAIN + c["body"], c["operator_sig"])
|
||||
if bad:
|
||||
raise Refuse(bad, ["operator"])
|
||||
if "countersignatures" not in SKIP:
|
||||
for i, (k, s) in enumerate(zip(c["owners"], c["countersigs"])):
|
||||
if s == bytes(64):
|
||||
raise Refuse("not_countersigned", ["owner", i])
|
||||
bad = ed_verify(k, ACCEPT_DOMAIN + c["body"], s)
|
||||
if bad:
|
||||
raise Refuse(bad, ["owner", i])
|
||||
return c
|
||||
|
||||
|
||||
def contract_at(b, at):
|
||||
try:
|
||||
return in_force(b)
|
||||
except Refuse as e:
|
||||
raise Refuse(e.reason, at + e.at)
|
||||
|
||||
|
||||
def overlaps(a, b):
|
||||
"""§3.5."""
|
||||
meet = a["start"] < b["end"] and b["start"] < a["end"]
|
||||
return meet and (a["channel"] == b["channel"] or any(x in b["carriers"] for x in a["carriers"]))
|
||||
|
||||
|
||||
def parties(c, split):
|
||||
"""§3.2: a split's parties in their order: its owners as it lists them, then the operator with a share."""
|
||||
s = c["splits"][split]
|
||||
out = [(c["owners"][i], p) for i, p in s["shares"]]
|
||||
if s["operator_ppm"] > 0:
|
||||
out.append((c["operator"], s["operator_ppm"]))
|
||||
return out
|
||||
|
||||
|
||||
def split_of(c, asset):
|
||||
for a, s in c["assets"]:
|
||||
if a == asset:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §4 the program-as-run
|
||||
|
||||
def num(v, top=U64):
|
||||
if not isinstance(v, int) or isinstance(v, bool) or not 0 <= v <= top:
|
||||
raise NoRead()
|
||||
return v
|
||||
|
||||
|
||||
def hexn(v, n):
|
||||
if not isinstance(v, str) or len(v) != 2 * n or any(c not in "0123456789abcdef" for c in v):
|
||||
raise NoRead()
|
||||
return bytes.fromhex(v)
|
||||
|
||||
|
||||
def text(v):
|
||||
if not isinstance(v, str):
|
||||
raise NoRead()
|
||||
return v
|
||||
|
||||
|
||||
def read_run(o):
|
||||
"""§4.1: the run, or NoRead."""
|
||||
if not isinstance(o, dict):
|
||||
raise NoRead()
|
||||
label = o.get("energy_provenance")
|
||||
if label is not None and label not in EVIDENCE:
|
||||
raise NoRead()
|
||||
if set(o) != set(RUN_MEMBERS) | set(EVIDENCE.get(label, [])):
|
||||
raise NoRead()
|
||||
r = {"channel_id": text(o["channel_id"]), "schedule_root": hexn(o["schedule_root"], 32),
|
||||
"window_start_ms": num(o["window_start_ms"]), "window_end_ms": num(o["window_end_ms"]),
|
||||
"work_schedule_ops": num(o["work_schedule_ops"]), "joules_micro": num(o["joules_micro"]),
|
||||
"signer_pubkey": hexn(o["signer_pubkey"], 32), "signer_id": text(o["signer_id"]), "sig": hexn(o["sig"], 64)}
|
||||
if not isinstance(o["schedule"], list) or not isinstance(o["aired"], list):
|
||||
raise NoRead()
|
||||
r["schedule"] = []
|
||||
for e in o["schedule"]:
|
||||
if not isinstance(e, dict) or set(e) != {"asset_id", "start_ms", "duration_ms", "kind"} or e.get("kind") not in KINDS:
|
||||
raise NoRead()
|
||||
x = {"asset_id": text(e["asset_id"]), "start_ms": num(e["start_ms"]), "duration_ms": num(e["duration_ms"]),
|
||||
"kind": e["kind"]}
|
||||
if x["start_ms"] + x["duration_ms"] > U64:
|
||||
raise NoRead()
|
||||
r["schedule"].append(x)
|
||||
r["aired"] = [num(a) for a in o["aired"]]
|
||||
g = o["grant"]
|
||||
if not isinstance(g, dict) or set(g) != {"grant_hash", "capability", "joule_ceiling_micro", "funds_ceiling"}:
|
||||
raise NoRead()
|
||||
r["grant"] = {"grant_hash": hexn(g["grant_hash"], 32), "capability": text(g["capability"]),
|
||||
"joule_ceiling_micro": num(g["joule_ceiling_micro"]),
|
||||
"funds_ceiling": None if g["funds_ceiling"] is None else num(g["funds_ceiling"])}
|
||||
r["parent"] = None if o["parent_receipt_hash"] is None else hexn(o["parent_receipt_hash"], 32)
|
||||
r["class"] = None
|
||||
if label is not None:
|
||||
c = {"label": label}
|
||||
if label in ("OnChipCounter", "CalibratedInstrument"):
|
||||
u = o["energy_uncertainty"]
|
||||
if not isinstance(u, dict) or set(u) != {"relative_ppm", "window_us"}:
|
||||
raise NoRead()
|
||||
c["ppm"], c["window"] = num(u["relative_ppm"], U32), num(u["window_us"])
|
||||
if label == "OnChipCounter":
|
||||
if o["energy_counter_filtering"] not in FILTERING:
|
||||
raise NoRead()
|
||||
c["filtering"] = FILTERING[o["energy_counter_filtering"]]
|
||||
if label == "CalibratedInstrument":
|
||||
c["ref"] = text(o["energy_calibration_ref"])
|
||||
r["class"] = c
|
||||
return r
|
||||
|
||||
|
||||
def leaf(e):
|
||||
a = e["asset_id"].encode()
|
||||
return b3(ENTRY_DOMAIN, be32(len(a)), a, be64(e["start_ms"]), be64(e["duration_ms"]), bytes([KINDS[e["kind"]]]))
|
||||
|
||||
|
||||
def merkle(hashes):
|
||||
"""jwp-receipts §2.2, each leaf of its first form; zero for none."""
|
||||
if not hashes:
|
||||
return ZERO
|
||||
level = [b3(LEAF, h) for h in hashes]
|
||||
while len(level) > 1:
|
||||
if len(level) % 2:
|
||||
level.append(level[-1])
|
||||
level = [b3(NODE, level[k], level[k + 1]) for k in range(0, len(level), 2)]
|
||||
return level[0]
|
||||
|
||||
|
||||
def class_bytes(c):
|
||||
"""energy-measurement §2.7's signed encoding; tag 0 for none."""
|
||||
if c is None:
|
||||
return b"\x00"
|
||||
out = bytes([CLASS_TAG[c["label"]]])
|
||||
if c["label"] == "OnChipCounter":
|
||||
out += be32(c["ppm"]) + be64(c["window"]) + bytes([c["filtering"]])
|
||||
elif c["label"] == "CalibratedInstrument":
|
||||
ref = c["ref"].encode()
|
||||
out += be32(c["ppm"]) + be64(c["window"]) + be32(len(ref)) + ref
|
||||
return out
|
||||
|
||||
|
||||
def run_payload(r):
|
||||
"""§4.2: the signing payload."""
|
||||
g = r["grant"]
|
||||
cid, cap, sid = r["channel_id"].encode(), g["capability"].encode(), r["signer_id"].encode()
|
||||
out = [RUN_DOMAIN, be32(len(cid)), cid, r["schedule_root"], be64(r["window_start_ms"]), be64(r["window_end_ms"]),
|
||||
be64(r["work_schedule_ops"]), be64(r["joules_micro"]), class_bytes(r["class"]), g["grant_hash"], be32(len(cap)),
|
||||
cap, be64(g["joule_ceiling_micro"])]
|
||||
out.append(b"\x00" if g["funds_ceiling"] is None else b"\x01" + be64(g["funds_ceiling"]))
|
||||
out.append(b"\x00" if r["parent"] is None else b"\x01" + r["parent"])
|
||||
out.append(be64(len(r["aired"])))
|
||||
out += [be64(a) for a in r["aired"]]
|
||||
out.append(be64(len(r["schedule"])))
|
||||
out += [leaf(e) for e in r["schedule"]]
|
||||
out += [r["signer_pubkey"], be32(len(sid)), sid]
|
||||
return b"".join(out)
|
||||
|
||||
|
||||
def run_identity(r):
|
||||
return b3(RUN_ID_DOMAIN, run_payload(r), r["sig"])
|
||||
|
||||
|
||||
def coheres(j, c):
|
||||
"""live-ads §8.5 rule 5: the figure and its class stand or fall together."""
|
||||
if c is None:
|
||||
return j == 0
|
||||
if j == 0:
|
||||
return False
|
||||
if c["label"] in ("OnChipCounter", "CalibratedInstrument"):
|
||||
if c["ppm"] == 0 or c["window"] == 0 or j * c["ppm"] < FLOOR:
|
||||
return False
|
||||
if c["label"] == "CalibratedInstrument" and all(ch in WHITE_SPACE for ch in c["ref"]):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def run_verifies(r):
|
||||
"""§4.3."""
|
||||
s = r["schedule"]
|
||||
if merkle([leaf(e) for e in s]) != r["schedule_root"] or r["work_schedule_ops"] != len(s):
|
||||
return False
|
||||
if r["window_start_ms"] >= r["window_end_ms"]:
|
||||
return False
|
||||
if any(a["start_ms"] + a["duration_ms"] != b["start_ms"] for a, b in zip(s, s[1:])):
|
||||
return False
|
||||
touch = [k for k, e in enumerate(s) if e["start_ms"] < r["window_end_ms"] and e["start_ms"] + e["duration_ms"] > r["window_start_ms"]]
|
||||
if r["aired"] != touch:
|
||||
return False
|
||||
if not coheres(r["joules_micro"], r["class"]):
|
||||
return False
|
||||
return ed_verify(r["signer_pubkey"], run_payload(r), r["sig"]) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §5 the instants
|
||||
|
||||
def instant_of(s):
|
||||
"""§5.4: the microseconds since 1970 a START-DATE states, or None."""
|
||||
m = DATE.fullmatch(s)
|
||||
if not m:
|
||||
return None
|
||||
y, mo, d, h, mi, sec = (int(x) for x in m.groups()[:6])
|
||||
frac, zone = m.group(7), m.group(8)
|
||||
if h > 23 or mi > 59 or sec > 59:
|
||||
return None
|
||||
try:
|
||||
days = datetime.date(y, mo, d).toordinal() - EPOCH
|
||||
except ValueError:
|
||||
return None
|
||||
off = 0
|
||||
if zone != b"Z":
|
||||
oh, om = int(zone[1:3]), int(zone[4:6])
|
||||
if oh > 23 or om > 59:
|
||||
return None
|
||||
off = (oh * 3600 + om * 60) * (1 if zone[:1] == b"+" else -1)
|
||||
secs = days * 86400 + h * 3600 + mi * 60 + sec - off
|
||||
if secs < 0:
|
||||
return None
|
||||
us = int((frac or b"").ljust(6, b"0")[:6] or b"0")
|
||||
return secs * 10 ** 6 + us
|
||||
|
||||
|
||||
def marker_instant(claim):
|
||||
"""A stitched claim's marker (stitched-presentation §5.1, its long field at offset 125): the
|
||||
instant its START-DATE states, for carriage 1 or 2 (§5.4); None otherwise."""
|
||||
carriage = claim[5]
|
||||
if carriage not in (1, 2):
|
||||
return None
|
||||
r = R(claim, 125)
|
||||
try:
|
||||
body = r.text()
|
||||
b = R(body)
|
||||
b.text()
|
||||
start_date = b.text()
|
||||
except ValueError:
|
||||
return None
|
||||
return instant_of(start_date)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §7 the statement
|
||||
|
||||
def read_statement(b):
|
||||
"""§7.6: the statement held to its rules, or Refuse at `statement`."""
|
||||
at = ["statement"]
|
||||
bad = head(b, STATEMENT_MAGIC)
|
||||
if bad:
|
||||
raise Refuse(bad, at)
|
||||
r = R(b, 5)
|
||||
try:
|
||||
s = {"contract": r.take(32), "party": r.take(32), "start": r.u(8), "end": r.u(8), "currency": r.take(3)}
|
||||
s["invoices"] = [r.take(32) for _ in range(r.u(2))]
|
||||
s["runs"] = [r.take(32) for _ in range(r.u(2))]
|
||||
s["lines"] = []
|
||||
for _ in range(r.u(2)):
|
||||
s["lines"].append({"split": r.u(2), "class": r.u(1), "impressions": r.u(8), "revenue": r.u(8), "ppm": r.u(4),
|
||||
"amount": r.u(8), "cited": r.u(4), "cites": r.take(32)})
|
||||
s["total"] = r.u(8)
|
||||
s["sig"] = r.take(64)
|
||||
except ValueError:
|
||||
raise Refuse("truncated", at)
|
||||
if r.p != len(b):
|
||||
raise Refuse("malformed", at)
|
||||
rules = (s["start"] < s["end"] and capitals(s["currency"]) and s["party"] != ZERO and ascending(s["invoices"])
|
||||
and ascending(s["runs"]) and ascending([(x["split"], x["class"]) for x in s["lines"]])
|
||||
and all(1 <= x["class"] <= 3 and x["impressions"] > 0 and x["cited"] > 0 and 1 <= x["ppm"] <= WHOLE
|
||||
and x["amount"] <= x["revenue"] for x in s["lines"]))
|
||||
if not rules and "statement-rules" not in SKIP:
|
||||
raise Refuse("share_statement_invalid", at)
|
||||
s["body"] = b[:-64]
|
||||
s["digest"] = sha(s["body"])
|
||||
return s
|
||||
|
||||
|
||||
def divide(revenue, ppms):
|
||||
"""§7.3: the largest-remainder rule. Ties go to the earlier party."""
|
||||
quota = [revenue * p for p in ppms]
|
||||
parts = [q // WHOLE for q in quota]
|
||||
left = revenue - sum(parts)
|
||||
order = sorted(range(len(ppms)), key=lambda i: (-(quota[i] % WHOLE), i))
|
||||
for i in order[:left]:
|
||||
parts[i] += 1
|
||||
return parts
|
||||
|
||||
|
||||
def cites_digest(cited):
|
||||
return sha(CITES_DOMAIN, *[bytes([k]) + i for k, i in sorted(cited)])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §8 verification
|
||||
|
||||
_LEDGERS = {}
|
||||
|
||||
|
||||
def ledger_outcome(d):
|
||||
"""receipt-ledger §7: a ledger document's outcome, by the ledger corpus's own verifier, and, when
|
||||
it is accepted, its tariff, its invoice and what each source states (receipt-ledger §5.2). A
|
||||
pure function of the document: each is verified once."""
|
||||
key = json.dumps(d, sort_keys=True)
|
||||
if key not in _LEDGERS:
|
||||
got, facts = RLV.verify(d), None
|
||||
if got["outcome"] == "accept":
|
||||
t = RLV.read_tariff(bytes.fromhex(d["tariff"]))
|
||||
inv = RLV.read_invoice(bytes.fromhex(d["invoice"]), ["invoice"])
|
||||
signers = [i for k, i in t["scope"] if k == 4]
|
||||
facts = (t, inv, [RLV.verify_source(s, signers) for s in d["sources"]])
|
||||
_LEDGERS[key] = (got, facts)
|
||||
return _LEDGERS[key]
|
||||
|
||||
|
||||
def attributed(c, start, end, runs, ledgers, docs):
|
||||
"""§8.2 step 6: every impression of the contract's carriers, placed and attributed (§5, §6)."""
|
||||
ids = [run_identity(r) for r in runs]
|
||||
out = []
|
||||
for k, (t, inv, facts) in enumerate(ledgers):
|
||||
prices = {PRESENTED: RLV.price(t, 4, 1), UNPLACED: RLV.price(t, 4, 1), BEACONS: RLV.price(t, 4, 2)}
|
||||
for j, f in enumerate(facts):
|
||||
at = ["ledger", k, "source", j]
|
||||
found = []
|
||||
if f["kind"] == RLV.ADS and (ROOM, f["room"]) in c["carriers"]:
|
||||
for rc in f["receipts"]:
|
||||
for i in rc["impressions"]:
|
||||
if not i["counts"]:
|
||||
continue
|
||||
recs = [(AD_RECEIPT, rc["identity"])]
|
||||
if f["claims_required"]:
|
||||
if i["claim"] is not None:
|
||||
recs.append((CLAIM, i["claim"]))
|
||||
cls = PRESENTED
|
||||
else:
|
||||
cls = BEACONS
|
||||
found.append((recs, cls, True, f["t0"] + rc["t"]))
|
||||
elif f["kind"] == RLV.STITCHED:
|
||||
given = [bytes.fromhex(x) for x in docs[k]["sources"][j]["claims"]]
|
||||
digests = [sha(g[:-64]) if len(g) >= 64 else None for g in given]
|
||||
for rec in f["records"]:
|
||||
if (STITCHER, rec["signer"]) not in c["carriers"]:
|
||||
continue
|
||||
for _, claims in rec["ads"]:
|
||||
for i, d in sorted((digests.index(d), d) for d in claims if d in digests):
|
||||
inst = marker_instant(given[i])
|
||||
found.append(([(POD_INPUT, rec["digest"]), (STITCHED_CLAIM, d)],
|
||||
PRESENTED if inst is not None else UNPLACED, inst is not None, inst))
|
||||
for recs, cls, placed, inst in found:
|
||||
imp = {"ledger": k, "source": j, "records": recs, "class": cls, "price": prices[cls], "invoice": inv["digest"],
|
||||
"at": None, "run": None, "entry": None, "asset": None, "split": c["between"]}
|
||||
if placed:
|
||||
if not (start <= inst < end and inst <= U64) and "impression-period" not in SKIP:
|
||||
raise Refuse("outside_period", at)
|
||||
holds = [n for n, r in enumerate(runs) if r["window_start_ms"] * 1000 <= inst < r["window_end_ms"] * 1000]
|
||||
entry = None
|
||||
if holds:
|
||||
sched = runs[holds[0]]["schedule"]
|
||||
es = [e for e, x in enumerate(sched) if x["start_ms"] * 1000 <= inst < (x["start_ms"] + x["duration_ms"]) * 1000]
|
||||
entry = es[0] if es else None
|
||||
if entry is None:
|
||||
if "window" not in SKIP:
|
||||
raise Refuse("outside_window", at)
|
||||
out.append(imp)
|
||||
continue
|
||||
asset = asset_of(sched, entry, c["rule"])
|
||||
if asset is not None:
|
||||
split = split_of(c, asset.encode())
|
||||
if split is None:
|
||||
if "unmapped" not in SKIP:
|
||||
raise Refuse("asset_unmapped", at)
|
||||
split = c["between"]
|
||||
imp["split"] = split
|
||||
imp.update({"at": inst, "run": ids[holds[0]], "entry": entry, "asset": asset})
|
||||
out.append(imp)
|
||||
return out
|
||||
|
||||
|
||||
def asset_of(sched, e, rule):
|
||||
"""§6.2: a programme's own asset, or by the rule; None for none."""
|
||||
if sched[e]["kind"] == "program":
|
||||
return sched[e]["asset_id"]
|
||||
if rule == PRECEDING:
|
||||
before = [x for x in sched[:e] if x["kind"] == "program"]
|
||||
return before[-1]["asset_id"] if before else None
|
||||
if rule == FOLLOWING:
|
||||
after = [x for x in sched[e + 1:] if x["kind"] == "program"]
|
||||
return after[0]["asset_id"] if after else None
|
||||
return None
|
||||
|
||||
|
||||
def groups_of(imps):
|
||||
g = {}
|
||||
for i in imps:
|
||||
e = g.setdefault((i["split"], i["class"]), {"impressions": 0, "revenue": 0, "cited": set()})
|
||||
e["impressions"] += 1
|
||||
e["revenue"] += i["price"]
|
||||
e["cited"].update(i["records"])
|
||||
if i["run"] is not None:
|
||||
e["cited"].add((RUN, i["run"]))
|
||||
e["cited"].add((INVOICE, i["invoice"]))
|
||||
return g
|
||||
|
||||
|
||||
def lines_of(c, party, groups):
|
||||
"""§7.2 to §7.4: the party's lines, each with its cited records and its split's division; None
|
||||
for a figure past its field."""
|
||||
out = []
|
||||
for split, cls in sorted(groups):
|
||||
g = groups[(split, cls)]
|
||||
ps = parties(c, split)
|
||||
mine = [n for n, (p, _) in enumerate(ps) if p == party]
|
||||
if not mine:
|
||||
continue
|
||||
if g["revenue"] > U64 or len(g["cited"]) > U32:
|
||||
return None
|
||||
parts = divide(g["revenue"], [p for _, p in ps])
|
||||
me = mine[0]
|
||||
out.append({"split": split, "class": cls, "impressions": g["impressions"], "revenue": g["revenue"], "ppm": ps[me][1],
|
||||
"amount": parts[me], "cited": len(g["cited"]), "cites": cites_digest(g["cited"]),
|
||||
"cited_records": sorted(g["cited"]), "division": [(p, s, a) for (p, s), a in zip(ps, parts)]})
|
||||
return out
|
||||
|
||||
|
||||
def verify(doc):
|
||||
"""§8.2: the outcome of a verification document."""
|
||||
try:
|
||||
if not isinstance(doc, dict) or set(doc) != {"contract", "contracts", "statement", "runs", "ledgers"}:
|
||||
raise Refuse("malformed", [])
|
||||
if not isinstance(doc["contracts"], list) or not isinstance(doc["runs"], list) or not isinstance(doc["ledgers"], list):
|
||||
raise Refuse("malformed", [])
|
||||
cb, sb = unhex(doc["contract"]), unhex(doc["statement"])
|
||||
others = [unhex(x) for x in doc["contracts"]]
|
||||
c = contract_at(cb, ["contract"]) # step 1
|
||||
for k, ob in enumerate(others): # step 2
|
||||
o = contract_at(ob, ["contracts", k])
|
||||
if o["digest"] == c["digest"]:
|
||||
continue
|
||||
if overlaps(o, c) and "overlap" not in SKIP:
|
||||
raise Refuse("contract_overlap", ["contracts", k])
|
||||
s = read_statement(sb) # step 3
|
||||
if "binding" not in SKIP and (s["contract"] != c["digest"] or s["currency"] != c["currency"]
|
||||
or (s["party"] != c["operator"] and s["party"] not in c["owners"])):
|
||||
raise Refuse("contract_mismatch", ["statement"])
|
||||
if "contract-period" not in SKIP and not (c["start"] <= s["start"] and s["end"] <= c["end"]):
|
||||
raise Refuse("outside_contract", ["statement"])
|
||||
if "statement-signature" not in SKIP:
|
||||
bad = ed_verify(c["operator"], STATEMENT_DOMAIN + s["body"], s["sig"])
|
||||
if bad:
|
||||
raise Refuse(bad, ["statement"])
|
||||
runs = [] # step 4
|
||||
for k, o in enumerate(doc["runs"]):
|
||||
try:
|
||||
r = read_run(o)
|
||||
except NoRead:
|
||||
raise Refuse("malformed", ["run", k])
|
||||
if "run-verify" not in SKIP and not run_verifies(r):
|
||||
raise Refuse("run_invalid", ["run", k])
|
||||
if "run-foreign" not in SKIP and (r["channel_id"].encode() != c["channel"] or r["signer_pubkey"] != c["channel_signer"]):
|
||||
raise Refuse("run_foreign", ["run", k])
|
||||
if "run-overlap" not in SKIP and any(r["window_start_ms"] < x["window_end_ms"] and x["window_start_ms"] < r["window_end_ms"]
|
||||
for x in runs):
|
||||
raise Refuse("run_overlap", ["run", k])
|
||||
runs.append(r)
|
||||
ledgers, seen, breaks = [], set(), set() # step 5
|
||||
for k, d in enumerate(doc["ledgers"]):
|
||||
if not isinstance(d, dict):
|
||||
raise Refuse("malformed", ["ledger", k, "tariff"])
|
||||
got, held = ledger_outcome(d)
|
||||
if got["outcome"] != "accept":
|
||||
raise Refuse(got["reason"], ["ledger", k] + got["at"])
|
||||
t, inv, facts = held
|
||||
if "invoice-foreign" not in SKIP and (inv["seller"] != c["operator"] or inv["currency"] != c["currency"]):
|
||||
raise Refuse("invoice_foreign", ["ledger", k])
|
||||
if "ledger-period" not in SKIP and not (s["start"] <= inv["start"] and inv["end"] <= s["end"]):
|
||||
raise Refuse("outside_period", ["ledger", k])
|
||||
records = {x for x in inv["records"] if 6 <= x[0] <= 9}
|
||||
these = {(f["room"], rc["cue"], rc["session"]) for f in facts if f["kind"] == RLV.ADS for rc in f["receipts"]}
|
||||
if "duplicate" not in SKIP and (records & seen or these & breaks):
|
||||
raise Refuse("impression_duplicate", ["ledger", k])
|
||||
seen |= records
|
||||
breaks |= these
|
||||
ledgers.append((t, inv, facts))
|
||||
imps = attributed(c, s["start"], s["end"], runs, ledgers, doc["ledgers"]) # step 6
|
||||
groups = groups_of(imps) # step 7
|
||||
for x in s["lines"]:
|
||||
g = groups.get((x["split"], x["class"]))
|
||||
holds = any(p == s["party"] for p, _ in parties(c, x["split"])) if x["split"] < len(c["splits"]) else False
|
||||
if "unsourced" not in SKIP and (g is None or not holds or x["impressions"] > g["impressions"]):
|
||||
raise Refuse("line_unsourced", ["statement"])
|
||||
lines = lines_of(c, s["party"], groups)
|
||||
if lines is None:
|
||||
raise Refuse("share_statement_mismatch", ["statement"])
|
||||
for x in s["lines"]:
|
||||
mine = [y for y in lines if (y["split"], y["class"]) == (x["split"], x["class"])]
|
||||
if mine and "citations" not in SKIP and (x["cited"] != mine[0]["cited"] or x["cites"] != mine[0]["cites"]):
|
||||
raise Refuse("citation_mismatch", ["statement"])
|
||||
total = sum(y["amount"] for y in lines)
|
||||
same = (s["invoices"] == sorted(inv["digest"] for _, inv, _ in ledgers)
|
||||
and s["runs"] == sorted(run_identity(r) for r in runs)
|
||||
and [{k: x[k] for k in ("split", "class", "impressions", "revenue", "ppm", "amount", "cited", "cites")} for x in s["lines"]]
|
||||
== [{k: y[k] for k in ("split", "class", "impressions", "revenue", "ppm", "amount", "cited", "cites")} for y in lines]
|
||||
and s["total"] == total and total <= U64)
|
||||
if not same and "mismatch" not in SKIP:
|
||||
raise Refuse("share_statement_mismatch", ["statement"])
|
||||
except Refuse as e:
|
||||
return {"outcome": "refuse", "reason": e.reason, "at": e.at}
|
||||
return accepted(c, s, imps, lines)
|
||||
|
||||
|
||||
def accepted(c, s, imps, lines):
|
||||
"""§8.3."""
|
||||
return {
|
||||
"outcome": "accept",
|
||||
"statement": s["digest"].hex(),
|
||||
"contract": s["contract"].hex(),
|
||||
"party": s["party"].hex(),
|
||||
"role": "operator" if s["party"] == c["operator"] else "owner",
|
||||
"currency": s["currency"].decode("ascii"),
|
||||
"start": s["start"],
|
||||
"end": s["end"],
|
||||
"invoices": [d.hex() for d in s["invoices"]],
|
||||
"runs": [d.hex() for d in s["runs"]],
|
||||
"impressions": [{"ledger": i["ledger"], "source": i["source"], "records": [[k, d.hex()] for k, d in i["records"]],
|
||||
"class": CLASS_NAMES[i["class"]], "price": i["price"], "at": i["at"],
|
||||
"run": None if i["run"] is None else i["run"].hex(), "entry": i["entry"], "asset": i["asset"],
|
||||
"split": i["split"]} for i in imps],
|
||||
"lines": [{"split": y["split"], "class": CLASS_NAMES[y["class"]], "impressions": y["impressions"], "revenue": y["revenue"],
|
||||
"ppm": y["ppm"], "amount": y["amount"], "cites": [[k, d.hex()] for k, d in y["cited_records"]],
|
||||
"division": [{"party": p.hex(), "ppm": pp, "part": a} for p, pp, a in y["division"]]} for y in lines],
|
||||
"total": s["total"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the corpus
|
||||
|
||||
def document(doc, v):
|
||||
"""A vector as a verification document (§8.1), its parts resolved by name."""
|
||||
def ledger(n):
|
||||
x = doc["ledgers"][n]
|
||||
return {"tariff": x["tariff"], "invoice": x["invoice"], "previous": x["previous"],
|
||||
"sources": [doc["sources"][s] for s in x["sources"]]}
|
||||
return {"contract": doc["contracts"][v["contract"]], "contracts": [doc["contracts"][n] for n in v["contracts"]],
|
||||
"statement": v["statement"], "runs": None if v["runs"] is None else [doc["runs"][n] for n in v["runs"]],
|
||||
"ledgers": [ledger(n) for n in v["ledgers"]]}
|
||||
|
||||
|
||||
def check(doc, quiet=False):
|
||||
"""The failures: every vector whose stated outcome is not the one this verifier reaches."""
|
||||
fails = []
|
||||
for v in doc.get("vectors", []):
|
||||
try:
|
||||
got = verify(document(doc, v))
|
||||
except Exception as e: # a crash on a mutated copy is a failure to verify it
|
||||
got = {"outcome": "crash", "error": "%s: %s" % (type(e).__name__, e)}
|
||||
ok = got == v.get("outcome")
|
||||
if not quiet:
|
||||
what = got["outcome"] + (" %s at %s" % (got["reason"], got["at"]) if got["outcome"] == "refuse" else "")
|
||||
print(("PASS" if ok else "FAIL") + " %s: %s" % (v.get("name"), what))
|
||||
if not ok:
|
||||
fails.append(v.get("name"))
|
||||
return fails
|
||||
|
||||
|
||||
def reasons_listed():
|
||||
"""§11's table, in order."""
|
||||
text_ = open(EXTENSION, encoding="utf-8").read()
|
||||
at = text_.index("\n## 11. Reasons")
|
||||
end = text_.find("\n## ", at + 1)
|
||||
return [m.group(1) for m in re.finditer(r"^\| `([a-z_]+)` \|", text_[at:end], re.M)]
|
||||
|
||||
|
||||
def flip_hex(h, at):
|
||||
b = bytearray(bytes.fromhex(h))
|
||||
b[at % len(b)] ^= 0x01
|
||||
return b.hex()
|
||||
|
||||
|
||||
def mutations(doc):
|
||||
"""Copies of the corpus, one defect each, that a verifier must reject."""
|
||||
out = []
|
||||
accepted_ = [(k, v) for k, v in enumerate(doc["vectors"]) if v["outcome"]["outcome"] == "accept" and v["outcome"]["lines"]]
|
||||
for k, v in accepted_:
|
||||
for at in (40, 70, 90, -100, -70, -1):
|
||||
m = copy.deepcopy(doc)
|
||||
m["vectors"][k]["statement"] = flip_hex(v["statement"], at)
|
||||
out.append(("%s: statement byte %d flipped" % (v["name"], at), m))
|
||||
m = copy.deepcopy(doc)
|
||||
m["vectors"][k]["runs"] = v["runs"][:-1]
|
||||
out.append(("%s: its last run dropped" % v["name"], m))
|
||||
m = copy.deepcopy(doc)
|
||||
m["vectors"][k]["ledgers"] = v["ledgers"][:-1]
|
||||
out.append(("%s: its last ledger document dropped" % v["name"], m))
|
||||
m = copy.deepcopy(doc)
|
||||
m["vectors"][k]["outcome"]["total"] += 1
|
||||
out.append(("%s: its stated total raised" % v["name"], m))
|
||||
m = copy.deepcopy(doc)
|
||||
m["vectors"][k]["outcome"]["lines"][0]["division"].pop()
|
||||
out.append(("%s: its first line's division cut short" % v["name"], m))
|
||||
m = copy.deepcopy(doc)
|
||||
m["vectors"][k]["outcome"]["impressions"][0]["asset"] = "a/ep-103"
|
||||
out.append(("%s: its first impression moved to another asset" % v["name"], m))
|
||||
for name, at in (("preceding", 60), ("preceding", -1), ("preceding", -130)):
|
||||
m = copy.deepcopy(doc)
|
||||
m["contracts"][name] = flip_hex(doc["contracts"][name], at)
|
||||
out.append(("the contract %s: byte %d flipped" % (name, at), m))
|
||||
m = copy.deepcopy(doc)
|
||||
m["runs"]["oct_25"]["schedule"][0]["asset_id"] = "b/doc-8"
|
||||
out.append(("the run of the 25th: its first programme renamed", m))
|
||||
m = copy.deepcopy(doc)
|
||||
m["runs"]["oct_16"]["sig"] = flip_hex(doc["runs"]["oct_16"]["sig"], 3)
|
||||
out.append(("the run of the 16th: its signature flipped", m))
|
||||
for k, v in enumerate(doc["vectors"]):
|
||||
if v["outcome"]["outcome"] == "refuse":
|
||||
m = copy.deepcopy(doc)
|
||||
m["vectors"][k]["outcome"]["reason"] = "share_statement_mismatch" if v["outcome"]["reason"] != "share_statement_mismatch" else "malformed"
|
||||
out.append(("%s: its stated reason changed" % v["name"], m))
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
CHECKS = ["contract-rules", "split-sums", "operator-signature", "countersignatures", "overlap", "statement-rules", "binding",
|
||||
"contract-period", "statement-signature", "run-verify", "run-foreign", "run-overlap", "invoice-foreign",
|
||||
"ledger-period", "duplicate", "impression-period", "window", "unmapped", "unsourced", "citations", "mismatch"]
|
||||
|
||||
|
||||
def main(corpus_dir):
|
||||
doc = json.load(open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8"))
|
||||
fails = check(doc)
|
||||
files = sorted(os.listdir(corpus_dir))
|
||||
if files != ["README.md", "vectors.json"]:
|
||||
fails.append("the corpus holds other files: %s" % files)
|
||||
seen = {v["outcome"].get("reason") for v in doc["vectors"]}
|
||||
listed = reasons_listed()
|
||||
missing = [r for r in listed + REUSED if r not in seen]
|
||||
if missing:
|
||||
fails.append("no vector is refused %s" % missing)
|
||||
print("%s every reason of §11 and every reused code refuses a vector (%d listed)" % ("PASS" if not missing else "FAIL", len(listed)))
|
||||
muts = mutations(doc)
|
||||
weak = [name for name, m in muts if not check(m, quiet=True)]
|
||||
print("%s every mutated copy of the corpus fails (%d copies)" % ("PASS" if not weak else "FAIL", len(muts)))
|
||||
fails += ["the mutated copy %r verifies" % w for w in weak]
|
||||
undetected = []
|
||||
for c in CHECKS:
|
||||
SKIP.add(c)
|
||||
try:
|
||||
if not check(doc, quiet=True):
|
||||
undetected.append(c)
|
||||
finally:
|
||||
SKIP.discard(c)
|
||||
print("%s the corpus catches the verifier with each of its checks dropped (%d checks)" % ("PASS" if not undetected else "FAIL", len(CHECKS)))
|
||||
fails += ["dropping the check %r goes unnoticed" % c for c in undetected]
|
||||
for f in fails:
|
||||
print("FAIL", f)
|
||||
return 1 if fails else 0
|
||||
|
||||
|
||||
def check_document(path):
|
||||
"""One verification document (§8.1): its outcome, printed. Status 0 only when it is accepted."""
|
||||
doc = json.load(open(path, encoding="utf-8"))
|
||||
got = verify(doc)
|
||||
print(json.dumps(got, indent=1))
|
||||
return 0 if got["outcome"] == "accept" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 3 and sys.argv[1] == "--document":
|
||||
sys.exit(check_document(sys.argv[2]))
|
||||
if len(sys.argv) != 2:
|
||||
print("usage: revenue_share_verify.py <live-conformance/share> | --document FILE", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
sys.exit(main(sys.argv[1]))
|
||||
72
wai/live-conformance/share/README.md
Normal file
72
wai/live-conformance/share/README.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# live-conformance/share — the revenue share
|
||||
|
||||
The corpus of [`extensions/revenue-share.md`](../../extensions/revenue-share.md) §12, in one
|
||||
file, `vectors.json`:
|
||||
|
||||
- `sources`: the ledger sources the invoices bill, by name, each a source document of
|
||||
receipt-ledger §5.1;
|
||||
- `ledgers`: ledger documents (receipt-ledger §7.1), by name, each a `tariff`, an `invoice`, the
|
||||
`previous` invoice or `null`, and the names of its `sources` in order;
|
||||
- `runs`: the channel's program-as-run windows, by name, each in its carriage (§4.1);
|
||||
- `contracts`: share contracts, by name, each hexadecimal;
|
||||
- `vectors`: verification documents (§8.1), each the name of its `contract`, the names of the other
|
||||
`contracts`, its `statement` (hexadecimal), the names of its `runs` (or `null`) and of its
|
||||
`ledgers`, with the `outcome` §8.3 gives it;
|
||||
- `keys`: the operator's, the two owners', the channel signer's, the two buyers' and a stranger's
|
||||
public keys.
|
||||
|
||||
**Share equivalence.** For each vector, a conforming verifier given its parts, resolved by name,
|
||||
reaches the outcome the vector states:
|
||||
|
||||
- `refuse`: the `reason` and where, `at`: `[]` for the document, `["contract"]`,
|
||||
`["contract", "operator"]` or `["contract", "owner", i]`, `["contracts", k]` and the same places
|
||||
after it, `["statement"]`, `["run", k]`, `["ledger", k]` or `["ledger", k, …]` followed by the
|
||||
place the receipt ledger gives (receipt-ledger §7.2), and `["ledger", k, "source", j]` for an
|
||||
impression of a ledger document's source;
|
||||
- `accept`: the statement's digest, the contract's, the `party` and its `role`, the `currency`,
|
||||
`start` and `end`, the `invoices` and `runs`, each `impression` of the contract's carriers (its
|
||||
ledger document, source, records, class, price, instant `at`, run, entry, asset and split), each
|
||||
line (its split, class, impressions, revenue, `ppm`, amount, the records it `cites`, each
|
||||
`[kind, identity]`, and the `division`: every party of its split with its `ppm` and `part`), and
|
||||
the `total`.
|
||||
|
||||
The channel, `fast-7`, airs two windows: the 16th's first five minutes (owner A's `a/ep-101`, an ad
|
||||
slot, owner B's `b/doc-7`) and the 25th's noon half hour (owner A's `a/ep-102`, an ad slot, owner
|
||||
B's `b/doc-8`). Its contract maps each owner's programmes to a split of its own (a third to A and
|
||||
two thirds to the operator; three fifths to B and two fifths to the operator) and names a between
|
||||
split of halves to the two owners, in which the operator holds no share. The accepted vectors:
|
||||
|
||||
- `owner_a_october`, `owner_b_october`, `operator_october`: October under the rule preceding, over
|
||||
two invoices of one chain. On the 16th, a break under a policy that does not require claims falls
|
||||
inside A's programme (three billable impressions) and a break under one that does falls inside
|
||||
B's (four presented); on the 25th, two stitched records' ads fall inside A's programme and the ad
|
||||
slot after it, a form-1 stream's ad inside B's, and three presentations of DASH events state no
|
||||
time and go to the between split. The three statements' parts of each split sum to its revenue;
|
||||
the between split's odd revenue gives its leftover millionth to the owner it lists first;
|
||||
- `owner_b_following`, `owner_a_between`: the same month under the rules following and between;
|
||||
- `owner_a_nothing_billed`: a statement of a period with no invoice, with no line.
|
||||
|
||||
Every other vector is refused, a control of one defect: each reason of §11, each code it reuses,
|
||||
and a ledger document the receipt ledger refuses, at its place.
|
||||
|
||||
**Provenance.** Generated here: `wai_revenue_share_vectors gen` writes `vectors.json` from fixed
|
||||
seeds (the operator, the owners, the channel signer, the buyers) and from sources taken unchanged
|
||||
from the ledger corpus [`ledger/`](../ledger/) (`ads_live_break`, `ads_presented`, `stitched_hls`,
|
||||
`stitched_form1`, `stitched_squeezeback`, and the vector `streams_stitched_by_others`, a ledger
|
||||
document of another seller), whose tariffs give the ad deciders and stitchers its own tariffs
|
||||
bill. When the ledger corpus is regenerated, so is this one. Nothing here was fetched from outside
|
||||
the repository.
|
||||
|
||||
Regenerate and check:
|
||||
|
||||
cd wai/wai-rs
|
||||
cargo run -q --no-default-features --features revenue_share --bin wai_revenue_share_vectors -- gen ../live-conformance/share
|
||||
cargo run -q --no-default-features --features revenue_share --bin wai_revenue_share_vectors -- verify ../live-conformance/share
|
||||
cargo test -q --no-default-features --features revenue_share --test revenue_share
|
||||
python3 -I ../live-conformance/revenue_share_verify.py ../live-conformance/share
|
||||
|
||||
The second implementation, `revenue_share_verify.py`, shares no code with `wai-rs`. It verifies
|
||||
each ledger document with the ledger corpus's own second implementation, reads every contract,
|
||||
run, marker and statement itself, recomputes every attribution, line, division and citation,
|
||||
requires every mutated copy of the corpus to fail, and requires the corpus to catch it with each of
|
||||
its own checks dropped.
|
||||
4838
wai/live-conformance/share/vectors.json
Normal file
4838
wai/live-conformance/share/vectors.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -404,6 +404,20 @@
|
|||
"edition": null,
|
||||
"registered": "2026-10-25"
|
||||
},
|
||||
{
|
||||
"capability": "wai.delivery.revenue_share",
|
||||
"kind": "record",
|
||||
"status": "registered",
|
||||
"media": null,
|
||||
"role": null,
|
||||
"tier": null,
|
||||
"licence": null,
|
||||
"replicate": null,
|
||||
"sets": [],
|
||||
"spec_section": "§5 Interactive, temporal & computed content",
|
||||
"edition": null,
|
||||
"registered": "2026-10-29"
|
||||
},
|
||||
{
|
||||
"capability": "wai.delivery.settlement",
|
||||
"kind": "record",
|
||||
|
|
@ -3073,6 +3087,13 @@
|
|||
"section": "§6.1",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WRC1",
|
||||
"kind": "magic",
|
||||
"document": "extensions/revenue-share.md",
|
||||
"section": "§3.1",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WRD1",
|
||||
"kind": "magic",
|
||||
|
|
@ -3108,6 +3129,13 @@
|
|||
"section": "§10.5.1",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WRS1",
|
||||
"kind": "magic",
|
||||
"document": "extensions/revenue-share.md",
|
||||
"section": "§7.1",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WSC1",
|
||||
"kind": "magic",
|
||||
|
|
|
|||
|
|
@ -373,6 +373,13 @@ wai:wai.delivery.ledger a wai:Capability ;
|
|||
wai:specSection "§5 Interactive, temporal & computed content" ;
|
||||
wai:registeredOn "2026-10-25"^^xsd:date .
|
||||
|
||||
wai:wai.delivery.revenue_share a wai:Capability ;
|
||||
rdfs:label "wai.delivery.revenue_share" ;
|
||||
wai:kind wai:kind-record ;
|
||||
wai:status wai:status-registered ;
|
||||
wai:specSection "§5 Interactive, temporal & computed content" ;
|
||||
wai:registeredOn "2026-10-29"^^xsd:date .
|
||||
|
||||
wai:wai.delivery.settlement a wai:Capability ;
|
||||
rdfs:label "wai.delivery.settlement" ;
|
||||
wai:kind wai:kind-record ;
|
||||
|
|
|
|||
|
|
@ -505,6 +505,14 @@ settlement = ["live_metering", "jwp_hash"]
|
|||
# chain; and the ad and delivery reports. SHA-256, Ed25519 and the sources' own readers; no new
|
||||
# dependency.
|
||||
receipt_ledger = ["live_metering", "settlement", "live_ads", "video_package", "stitched_presentation"]
|
||||
# Revenue share (extensions/revenue-share): `wai.delivery.revenue_share` — the share contract (WRC1)
|
||||
# an operator and every owner it names sign, mapping each asset of a channel to a split of integer
|
||||
# millionths, and the statement (WRS1) of one party and one period: the ledger's verified invoices
|
||||
# (receipt_ledger) joined with the channel's program-as-run (video_channel, read from its JSON
|
||||
# carriage), each impression attributed to the programme on air at its instant, each split's
|
||||
# revenue divided by the largest-remainder rule. SHA-256, Ed25519 and the sources' own readers; no
|
||||
# new dependency.
|
||||
revenue_share = ["receipt_ledger", "video_channel"]
|
||||
# Stream audit (extensions/stream-audit): `wai.delivery.audit` — what a content owner gathers from the
|
||||
# players of a stream another service delivers over HTTP: settlement acknowledgements of every object
|
||||
# by plain SHA-256 (settlement §14), one chain per origin, under a fresh session key; presentation
|
||||
|
|
@ -1300,6 +1308,11 @@ name = "wai_receipt_ledger_vectors"
|
|||
path = "src/bin/wai_receipt_ledger_vectors.rs"
|
||||
required-features = ["receipt_ledger"]
|
||||
|
||||
[[bin]]
|
||||
name = "wai_revenue_share_vectors"
|
||||
path = "src/bin/wai_revenue_share_vectors.rs"
|
||||
required-features = ["revenue_share"]
|
||||
|
||||
[[bin]]
|
||||
name = "wai_fleet_vectors"
|
||||
path = "src/bin/wai_fleet_vectors.rs"
|
||||
|
|
@ -2311,6 +2324,11 @@ name = "receipt_ledger"
|
|||
path = "tests/receipt_ledger.rs"
|
||||
required-features = ["receipt_ledger"]
|
||||
|
||||
[[test]]
|
||||
name = "revenue_share"
|
||||
path = "tests/revenue_share.rs"
|
||||
required-features = ["revenue_share"]
|
||||
|
||||
[[test]]
|
||||
name = "fleet"
|
||||
path = "tests/fleet.rs"
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@
|
|||
//! (`../setup_agent.rs`);
|
||||
//! - `enrolment.create`, `enrolment.list` (`wai enrolment create|list`) and `report.create`,
|
||||
//! `report.export` (`wai report create|export`): a fleet's nodes enrolled and its reports,
|
||||
//! against a fleet service (extensions/fleet.md §9; `fleet.rs`).
|
||||
//! against a fleet service (extensions/fleet.md §9; `fleet.rs`);
|
||||
//! - `contract.create`, `contract.update` (`wai contract create|accept`) and `statement.create`,
|
||||
//! `statement.verify` (`wai statement create|verify`): a channel's share contract and the
|
||||
//! revenue-share statements recomputed from the ledger's invoices and the channel's
|
||||
//! program-as-run (extensions/revenue-share.md §10; `share.rs`).
|
||||
//!
|
||||
//! [`envelope`] holds the agent rules every command keeps: the exit codes, the JSON envelope,
|
||||
//! `--dry-run`, `--yes`.
|
||||
|
|
@ -41,6 +45,7 @@ mod dev;
|
|||
pub(crate) mod envelope;
|
||||
mod fleet;
|
||||
mod hello;
|
||||
mod share;
|
||||
mod verify;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
|
|
@ -73,6 +78,11 @@ pub const VERBS: &[Impl] = &[
|
|||
Impl { id: "enrolment.list", run: fleet::enrolment_list, json: Json::Native },
|
||||
Impl { id: "report.create", run: fleet::report_create, json: Json::Native },
|
||||
Impl { id: "report.export", run: fleet::report_export, json: Json::Native },
|
||||
// The revenue share (extensions/revenue-share.md §10).
|
||||
Impl { id: "contract.create", run: share::contract_create, json: Json::Native },
|
||||
Impl { id: "contract.update", run: share::contract_update, json: Json::Native },
|
||||
Impl { id: "statement.create", run: share::statement_create, json: Json::Native },
|
||||
Impl { id: "statement.verify", run: share::statement_verify, json: Json::Native },
|
||||
];
|
||||
|
||||
/// An operation's synopsis, from the catalogue, for a usage error.
|
||||
|
|
|
|||
260
wai/wai-rs/src/bin/wai_cli/dx/share.rs
Normal file
260
wai/wai-rs/src/bin/wai_cli/dx/share.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
//! The revenue share's operations (extensions/revenue-share.md §10): `wai contract create`
|
||||
//! (`contract.create`), `wai contract accept` (`contract.update`), `wai statement create`
|
||||
//! (`statement.create`) and `wai statement verify` (`statement.verify`), on files on this machine.
|
||||
//!
|
||||
//! They keep the agent rules of [`super::envelope`], as every operation of the catalogue does:
|
||||
//! the exit codes, the JSON envelope with `--json` (data to standard output, logs to standard
|
||||
//! error), `--dry-run` on the three that write (every file through `out.rs`, nothing written),
|
||||
//! and `--yes` before a file that exists is replaced (asked at a terminal; without one, exit 4
|
||||
//! with the proposed change and the confirming command). A refusal by the extension's text has
|
||||
//! its reason as the code (`not_countersigned`, `outside_window`, ...), at exit 1.
|
||||
|
||||
use super::envelope::{self as agent, EXIT_FAILED, EXIT_INTERNAL, EXIT_NOT_FOUND, EXIT_PERMISSION, OpError};
|
||||
#[cfg(feature = "revenue_share")]
|
||||
use super::envelope::EXIT_USAGE;
|
||||
use crate::out;
|
||||
use serde_json::Value;
|
||||
#[cfg(feature = "revenue_share")]
|
||||
use serde_json::json;
|
||||
|
||||
type R = Result<bool, OpError>;
|
||||
|
||||
/// Print what an operation did: its envelope with `--json`, its text otherwise; in a dry run of
|
||||
/// an operation that writes, with the plan of what it would have written.
|
||||
#[cfg_attr(not(feature = "revenue_share"), allow(dead_code))]
|
||||
fn done(op: &str, writes: bool, data: Value, text: &str) -> R {
|
||||
let plan = (writes && out::dry_run()).then(out::plan);
|
||||
if agent::ctx().json {
|
||||
agent::print(&agent::envelope_with(op, true, data, plan));
|
||||
} else if plan.is_some() {
|
||||
println!("{text}");
|
||||
println!("dry run: nothing was written. Without --dry-run, wai would:");
|
||||
for l in out::plan_lines() {
|
||||
println!(" {l}");
|
||||
}
|
||||
} else {
|
||||
println!("{text}");
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// A file that could not be read or written.
|
||||
#[cfg_attr(not(feature = "revenue_share"), allow(dead_code))]
|
||||
fn io_fail(path: &str, e: &std::io::Error, hint: &str) -> OpError {
|
||||
match e.kind() {
|
||||
std::io::ErrorKind::NotFound => OpError::new(EXIT_NOT_FOUND, "not-found", format!("{path}: {e}"), hint),
|
||||
std::io::ErrorKind::PermissionDenied => OpError::new(EXIT_PERMISSION, "permission-denied", format!("{path}: {e}"), "the file's owner runs this"),
|
||||
_ => OpError::new(EXIT_INTERNAL, "io-error", format!("{path}: {e}"), hint),
|
||||
}
|
||||
}
|
||||
|
||||
/// A refusal by the extension's text, its reason as the code.
|
||||
#[cfg_attr(not(feature = "revenue_share"), allow(dead_code))]
|
||||
fn refused(what: &str, reason: &'static str, at: &Value) -> OpError {
|
||||
OpError::new(EXIT_FAILED, reason, format!("{what} is refused: {reason} at {at}"), "extensions/revenue-share.md §11 names each reason")
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "revenue_share"), allow(dead_code))]
|
||||
fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
/// A key file: 32 bytes, or 64 hexadecimal digits.
|
||||
#[cfg(feature = "revenue_share")]
|
||||
fn read_key(path: &str, what: &str) -> Result<ed25519_dalek::SigningKey, OpError> {
|
||||
let raw = std::fs::read(path).map_err(|e| io_fail(path, &e, what))?;
|
||||
let seed: [u8; 32] = if raw.len() == 32 {
|
||||
raw.try_into().expect("32")
|
||||
} else {
|
||||
std::str::from_utf8(&raw)
|
||||
.ok()
|
||||
.map(str::trim)
|
||||
.filter(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()))
|
||||
.map(|s| (0..32).map(|i| u8::from_str_radix(&s[2 * i..2 * i + 2], 16).expect("hex")).collect::<Vec<u8>>())
|
||||
.and_then(|b| b.try_into().ok())
|
||||
.ok_or_else(|| OpError::usage(format!("{path}: 32 bytes, or 64 hexadecimal digits"), what))?
|
||||
};
|
||||
Ok(ed25519_dalek::SigningKey::from_bytes(&seed))
|
||||
}
|
||||
|
||||
#[cfg(feature = "revenue_share")]
|
||||
fn read_json(path: &str, what: &str) -> Result<Value, OpError> {
|
||||
let b = std::fs::read(path).map_err(|e| io_fail(path, &e, what))?;
|
||||
serde_json::from_slice(&b).map_err(|e| OpError::usage(format!("{path}: not JSON: {e}"), what))
|
||||
}
|
||||
|
||||
/// Write `bytes` to `file`, asking first when it exists; in a dry run, plan it.
|
||||
#[cfg(feature = "revenue_share")]
|
||||
fn write(file: &str, bytes: &[u8], what: &str) -> Result<(), OpError> {
|
||||
if !out::dry_run() && std::path::Path::new(file).exists() {
|
||||
agent::confirm(&format!("{file} exists, and {what} would replace it"), vec![json!({"write": file, "bytes": bytes.len(), "replaces": true})])?;
|
||||
}
|
||||
out::write(file, bytes).map_err(|e| io_fail(file, &e, "check the directory"))
|
||||
}
|
||||
|
||||
#[cfg(feature = "revenue_share")]
|
||||
fn required<'a>(args: &'a [String], flag: &str, what: &str, usage: &str) -> Result<&'a str, OpError> {
|
||||
super::opt(args, flag).ok_or_else(|| OpError::usage(format!("{flag}: {what}"), usage))
|
||||
}
|
||||
|
||||
#[cfg(feature = "revenue_share")]
|
||||
fn number(args: &[String], flag: &str, usage: &str) -> Result<u64, OpError> {
|
||||
let v = required(args, flag, "microseconds since 1970", usage)?;
|
||||
v.parse().map_err(|_| OpError::usage(format!("{flag} {v}: microseconds since 1970, an integer"), usage))
|
||||
}
|
||||
|
||||
/// What a person reads of a contract: its digest, channel, period and who has countersigned.
|
||||
#[cfg(feature = "revenue_share")]
|
||||
fn contract_json(c: &wai::revenue_share::Contract, file: &str) -> Value {
|
||||
json!({
|
||||
"digest": hex(&c.digest()), "operator": hex(&c.operator), "channel": String::from_utf8_lossy(&c.channel), "start": c.start, "end": c.end,
|
||||
"currency": String::from_utf8_lossy(&c.currency), "rule": wai::revenue_share::rule::name(c.rule),
|
||||
"owners": c.owners.iter().zip(&c.countersigs).map(|(k, s)| json!({"owner": hex(k), "countersigned": *s != [0; 64]})).collect::<Vec<_>>(),
|
||||
"in_force": c.check_countersigs().is_ok(), "contract": hex(&c.to_bytes()), "file": file,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "revenue_share"))]
|
||||
pub fn contract_create(_: &[String]) -> R {
|
||||
Err(crate::door::not_built(wai::catalogue::op("contract.create").expect("listed")))
|
||||
}
|
||||
|
||||
/// `wai contract create <terms.json> --key <file> [--out F]`: the operator's contract from its
|
||||
/// terms, every countersignature zero (§3.3).
|
||||
#[cfg(feature = "revenue_share")]
|
||||
pub fn contract_create(args: &[String]) -> R {
|
||||
use wai::revenue_share::{Contract, terms};
|
||||
let usage = super::usage("contract.create");
|
||||
let with_value = ["--key", "--out"];
|
||||
let pos = agent::check_args(args, &with_value, &with_value, 1, &usage)?;
|
||||
let path = *pos.first().ok_or_else(|| OpError::usage("the terms: a JSON file", &usage))?;
|
||||
let key = read_key(required(args, "--key", "the operator key's file", &usage)?, "the operator key's file (32 bytes, or 64 hexadecimal digits)")?;
|
||||
let t = read_json(path, "the contract's terms")?;
|
||||
let c = terms(&t).map_err(|e| OpError::usage(format!("{path}: {e}"), &usage))?.offer(&key);
|
||||
let bytes = c.to_bytes();
|
||||
// Its own rules, before anything is written.
|
||||
Contract::read(&bytes).map_err(|r| OpError::new(EXIT_USAGE, r, format!("the contract is refused: {r}"), "extensions/revenue-share.md §3.4 states the rules"))?;
|
||||
let file = super::opt(args, "--out").map(str::to_string).unwrap_or_else(|| format!("{}.wrc", &hex(&c.digest())[..16]));
|
||||
write(&file, &bytes, "the contract")?;
|
||||
let text = format!(
|
||||
"contract {}… of {} for [{}, {}): {} owner(s) to countersign with `wai contract accept`{}",
|
||||
&hex(&c.digest())[..16],
|
||||
String::from_utf8_lossy(&c.channel),
|
||||
c.start,
|
||||
c.end,
|
||||
c.owners.len(),
|
||||
if out::dry_run() { String::new() } else { format!("; written to {file}") }
|
||||
);
|
||||
done("contract.create", true, contract_json(&c, &file), &text)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "revenue_share"))]
|
||||
pub fn contract_update(_: &[String]) -> R {
|
||||
Err(crate::door::not_built(wai::catalogue::op("contract.update").expect("listed")))
|
||||
}
|
||||
|
||||
/// `wai contract accept <contract> --key <file> [--out F]`: an owner's countersignature added,
|
||||
/// after the contract's rules and the operator's signature are checked (§3.3, §3.4).
|
||||
#[cfg(feature = "revenue_share")]
|
||||
pub fn contract_update(args: &[String]) -> R {
|
||||
use wai::revenue_share::Contract;
|
||||
let usage = super::usage("contract.update");
|
||||
let with_value = ["--key", "--out"];
|
||||
let pos = agent::check_args(args, &with_value, &with_value, 1, &usage)?;
|
||||
let path = *pos.first().ok_or_else(|| OpError::usage("the contract's file", &usage))?;
|
||||
let key = read_key(required(args, "--key", "the owner key's file", &usage)?, "the owner key's file (32 bytes, or 64 hexadecimal digits)")?;
|
||||
let b = std::fs::read(path).map_err(|e| io_fail(path, &e, "the contract's file"))?;
|
||||
let c = Contract::read(&b).map_err(|r| refused("the contract", r, &json!(["contract"])))?;
|
||||
c.check_operator().map_err(|(r, at)| refused("the contract", r, &at))?;
|
||||
let c = c.countersign(&key).ok_or_else(|| {
|
||||
OpError::new(EXIT_FAILED, "contract_mismatch", "the key is not one of the contract's owners", "give the key of an owner the contract names")
|
||||
})?;
|
||||
let file = super::opt(args, "--out").unwrap_or(path).to_string();
|
||||
write(&file, &c.to_bytes(), "the countersigned contract")?;
|
||||
let signed = c.countersigs.iter().filter(|s| **s != [0; 64]).count();
|
||||
let text = format!(
|
||||
"contract {}… countersigned by {}…: {signed} of {} owner(s){}{}",
|
||||
&hex(&c.digest())[..16],
|
||||
&hex(&key.verifying_key().to_bytes())[..16],
|
||||
c.owners.len(),
|
||||
if c.check_countersigs().is_ok() { ", in force" } else { "" },
|
||||
if out::dry_run() { String::new() } else { format!("; written to {file}") }
|
||||
);
|
||||
done("contract.update", true, contract_json(&c, &file), &text)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "revenue_share"))]
|
||||
pub fn statement_create(_: &[String]) -> R {
|
||||
Err(crate::door::not_built(wai::catalogue::op("statement.create").expect("listed")))
|
||||
}
|
||||
|
||||
/// A statement's outcome, for a person: its party, its lines and its total.
|
||||
#[cfg(feature = "revenue_share")]
|
||||
fn summarise(o: &Value) -> String {
|
||||
let d = o["statement"].as_str().unwrap_or("");
|
||||
let party = o["party"].as_str().unwrap_or("");
|
||||
let mut t = format!(
|
||||
"statement {}… of the {} {}… for [{}, {}): {} line(s), total {} millionths of a minor unit of {}",
|
||||
&d[..16.min(d.len())],
|
||||
o["role"].as_str().unwrap_or("?"),
|
||||
&party[..16.min(party.len())],
|
||||
o["start"],
|
||||
o["end"],
|
||||
o["lines"].as_array().map_or(0, Vec::len),
|
||||
o["total"],
|
||||
o["currency"].as_str().unwrap_or("?"),
|
||||
);
|
||||
for l in o["lines"].as_array().into_iter().flatten() {
|
||||
t.push_str(&format!("\n split {} {:<9} {} impression(s), revenue {}, share {} ppm, amount {}", l["split"], l["class"].as_str().unwrap_or(""), l["impressions"], l["revenue"], l["ppm"], l["amount"]));
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// `wai statement create <inputs.json> --party <key> --start µs --end µs --key <file> [--out F]`:
|
||||
/// the statement of a party and a period, computed from a verification document without its
|
||||
/// statement (§8.1), signed by the operator and written with its document.
|
||||
#[cfg(feature = "revenue_share")]
|
||||
pub fn statement_create(args: &[String]) -> R {
|
||||
let usage = super::usage("statement.create");
|
||||
let with_value = ["--party", "--start", "--end", "--key", "--out"];
|
||||
let pos = agent::check_args(args, &with_value, &with_value, 1, &usage)?;
|
||||
let path = *pos.first().ok_or_else(|| OpError::usage("the inputs: a verification document without its statement", &usage))?;
|
||||
let party_hex = required(args, "--party", "the party's key: 64 hexadecimal digits", &usage)?;
|
||||
let party: [u8; 32] = (party_hex.len() == 64 && party_hex.bytes().all(|c| c.is_ascii_hexdigit()))
|
||||
.then(|| (0..32).map(|i| u8::from_str_radix(&party_hex[2 * i..2 * i + 2], 16).expect("hex")).collect::<Vec<u8>>())
|
||||
.and_then(|b| b.try_into().ok())
|
||||
.ok_or_else(|| OpError::usage("--party: the party's key, 64 hexadecimal digits", &usage))?;
|
||||
let (start, end) = (number(args, "--start", &usage)?, number(args, "--end", &usage)?);
|
||||
let key = read_key(required(args, "--key", "the operator key's file", &usage)?, "the operator key's file (32 bytes, or 64 hexadecimal digits)")?;
|
||||
let inputs = read_json(path, "the statement's inputs")?;
|
||||
let (s, doc) = wai::revenue_share::create(&inputs, &key, party, start, end).map_err(|(r, at)| refused("the statement", r, &at))?;
|
||||
let o = wai::revenue_share::outcome_json(&wai::revenue_share::verify(&doc));
|
||||
let file = super::opt(args, "--out").map(str::to_string).unwrap_or_else(|| format!("{}.wai-share.json", &hex(&s.digest())[..16]));
|
||||
let text_out = serde_json::to_string_pretty(&doc).expect("JSON") + "\n";
|
||||
write(&file, text_out.as_bytes(), "the statement's document")?;
|
||||
let text = format!("{}{}", summarise(&o), if out::dry_run() { String::new() } else { format!("\nwritten to {file}; check it anywhere with `wai statement verify {file}`") });
|
||||
done("statement.create", true, json!({"file": file, "statement": hex(&s.to_bytes()), "outcome": o}), &text)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "revenue_share"))]
|
||||
pub fn statement_verify(_: &[String]) -> R {
|
||||
Err(crate::door::not_built(wai::catalogue::op("statement.verify").expect("listed")))
|
||||
}
|
||||
|
||||
/// `wai statement verify <document.json>`: a verification document's outcome (§8.3); a refusal is
|
||||
/// exit 1 with its reason as the code.
|
||||
#[cfg(feature = "revenue_share")]
|
||||
pub fn statement_verify(args: &[String]) -> R {
|
||||
let usage = super::usage("statement.verify");
|
||||
let pos = agent::check_args(args, &[], &[], 1, &usage)?;
|
||||
let path = *pos.first().ok_or_else(|| OpError::usage("the verification document", &usage))?;
|
||||
let doc = read_json(path, "the verification document")?;
|
||||
match wai::revenue_share::verify(&doc) {
|
||||
Ok(a) => {
|
||||
let o = wai::revenue_share::accepted_json(&a);
|
||||
let text = summarise(&o);
|
||||
done("statement.verify", false, o, &text)
|
||||
}
|
||||
Err((r, at)) => Err(refused("the statement", r, &at)),
|
||||
}
|
||||
}
|
||||
67
wai/wai-rs/src/bin/wai_revenue_share_vectors.rs
Normal file
67
wai/wai-rs/src/bin/wai_revenue_share_vectors.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
//! Conformance corpus for the revenue share (`live-conformance/share/`,
|
||||
//! extensions/revenue-share.md §12).
|
||||
//!
|
||||
//! wai_revenue_share_vectors gen <corpus-dir> write vectors.json, from fixed seeds and the
|
||||
//! ledger corpus it reads
|
||||
//! wai_revenue_share_vectors verify <corpus-dir> vectors.json regenerates byte for byte, and
|
||||
//! every vector gets the outcome it states
|
||||
//!
|
||||
//! The corpus directory is `wai/live-conformance/share`; the ledger corpus it reads is found from it.
|
||||
//! The generator and this verifier are one implementation. The corpus's second implementation is
|
||||
//! `live-conformance/revenue_share_verify.py`, written from the extension's text and sharing no
|
||||
//! code with this binary.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use wai::revenue_share::corpus;
|
||||
|
||||
fn wai_dir(corpus_dir: &Path) -> PathBuf {
|
||||
corpus_dir.join("..").join("..")
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
match (a.get(1).map(String::as_str), a.get(2)) {
|
||||
(Some("gen"), Some(d)) => {
|
||||
let p = Path::new(d).join("vectors.json");
|
||||
let text = match corpus::generate(&wai_dir(Path::new(d))) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("generate: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
if let Err(e) = std::fs::create_dir_all(d).and_then(|_| std::fs::write(&p, text)) {
|
||||
eprintln!("write {}: {e}", p.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("wrote {}", p.display());
|
||||
}
|
||||
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
|
||||
_ => {
|
||||
eprintln!("usage: wai_revenue_share_vectors gen|verify <corpus-dir>");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verify(dir: &Path) -> i32 {
|
||||
let p = dir.join("vectors.json");
|
||||
let doc = match std::fs::read_to_string(&p) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("read {}: {e}", p.display());
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let mut fail = 0;
|
||||
let same = corpus::generate(&wai_dir(dir)).is_ok_and(|g| g == doc);
|
||||
println!("{} vectors.json regenerates byte for byte from its seeds and the ledger corpus", if same { "PASS" } else { "FAIL" });
|
||||
fail += (!same) as i32;
|
||||
let fails = corpus::verify(&doc);
|
||||
for f in &fails {
|
||||
println!("FAIL {f}");
|
||||
}
|
||||
println!("{} every vector gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
|
||||
fail += (!fails.is_empty()) as i32;
|
||||
(fail != 0) as i32
|
||||
}
|
||||
|
|
@ -297,6 +297,10 @@ const S_ANY: &str = r#"{"description": "The tool's structured result"}"#;
|
|||
const S_JSONRPC: &str = r#"{"description": "One JSON-RPC 2.0 message per line, the Model Context Protocol"}"#;
|
||||
const S_SETUP: &str = r#"{"type":"object","required":["project","dry_run","files"]}"#;
|
||||
const S_APP_CHECK: &str = r#"{"type":"object","required":["program","errors","failures","checked"]}"#;
|
||||
// The revenue share's outputs (extensions/revenue-share.md §10).
|
||||
const S_SHARE_CONTRACT: &str = r#"{"type":"object","required":["digest","operator","channel","owners","in_force","contract","file"],"description":"The share contract (WRC1, hexadecimal), its digest, each owner and whether it has countersigned, and the file it went to"}"#;
|
||||
const S_SHARE_STATEMENT: &str = r#"{"type":"object","required":["file","statement","outcome"],"description":"The statement (WRS1, hexadecimal), the file its verification document went to, and its outcome, recomputed here"}"#;
|
||||
const S_SHARE_OUTCOME: &str = r#"{"type":"object","required":["outcome","statement","contract","party","role","lines","total"],"description":"An accepted statement's outcome (revenue-share §8.3): its lines, each split's division, every impression attributed, and the total"}"#;
|
||||
const S_ENROLMENT: &str = r#"{"type":"object","required":["node","role","organisation","digest","enrolment"],"description":"The enrolment (WFE1, hexadecimal), its digest, and the file and service it went to"}"#;
|
||||
const S_ENROLMENTS: &str = r#"{"type":"object","required":["enrolments"],"description":"Each node the fleet service holds enrolled: its key, role, label and records"}"#;
|
||||
const S_REPORT: &str = r#"{"type":"object","required":["report","outcome"],"description":"The report's digest and its outcome as extensions/fleet.md §7.3 states it"}"#;
|
||||
|
|
@ -1478,6 +1482,83 @@ http vectors diff|fuzz <dir> --seed S --count N --out F",
|
|||
route: Some(room("GET", "/call")),
|
||||
..OP
|
||||
},
|
||||
// ─── the revenue share (extensions/revenue-share.md §10) ───
|
||||
Op {
|
||||
id: "contract.create",
|
||||
summary: "A channel's share contract from its terms, signed with the operator key; each owner countersigns it",
|
||||
cli: Some("contract create"),
|
||||
usage: "contract create <terms.json> --key <file> [--out F] [--yes] the share contract (WRC1) of the terms, signed
|
||||
with the operator key, every countersignature still to come, written to F (<digest>.wrc); each owner adds
|
||||
its own with `wai contract accept`; replacing a file waits for --yes (--confirm, deprecated, is the same)",
|
||||
effect: Effect::WritesProject,
|
||||
stability: Stability::Preview,
|
||||
input: &[
|
||||
pos("terms", Ty::Path, true, "The terms: start, end, currency, channel, channel_signer, rule, between, carriers, owners, splits and assets."),
|
||||
flag("key", "--key", Ty::Path, true, "The operator key's file: 32 bytes, or 64 hexadecimal digits."),
|
||||
flag("out", "--out", Ty::Path, false, "The contract's file (<digest>.wrc)."),
|
||||
],
|
||||
output: Output::Json(S_SHARE_CONTRACT),
|
||||
receipt: true,
|
||||
needs: Needs::All(&["revenue_share"]),
|
||||
confirm: Some(Confirm::AnyFile),
|
||||
..OP
|
||||
},
|
||||
Op {
|
||||
id: "contract.update",
|
||||
summary: "An owner's countersignature added to a share contract, after its rules and the operator's signature are checked",
|
||||
cli: Some("contract accept"),
|
||||
usage: "contract accept <contract> --key <file> [--out F] [--yes] the owner's countersignature added to the contract,
|
||||
written to F (the contract's own file when F is not given); replacing a file waits for --yes (--confirm,
|
||||
deprecated, is the same)",
|
||||
effect: Effect::WritesProject,
|
||||
stability: Stability::Preview,
|
||||
input: &[
|
||||
pos("contract", Ty::Path, true, "The contract's file (WRC1)."),
|
||||
flag("key", "--key", Ty::Path, true, "The owner key's file: 32 bytes, or 64 hexadecimal digits."),
|
||||
flag("out", "--out", Ty::Path, false, "Where the countersigned contract goes (the contract's own file)."),
|
||||
],
|
||||
output: Output::Json(S_SHARE_CONTRACT),
|
||||
receipt: true,
|
||||
needs: Needs::All(&["revenue_share"]),
|
||||
confirm: Some(Confirm::AnyFile),
|
||||
..OP
|
||||
},
|
||||
Op {
|
||||
id: "statement.create",
|
||||
summary: "A party's revenue-share statement for a period, recomputed from the ledger's invoices and the channel's program-as-run, signed with the operator key",
|
||||
cli: Some("statement create"),
|
||||
usage: "statement create <inputs.json> --party <key> --start µs --end µs --key <file> [--out F] [--yes] the statement
|
||||
(WRS1) of the party for [start, end), computed from a verification document without its statement (the
|
||||
contract, other contracts, runs and ledger documents), signed with the operator key and written with its
|
||||
document to F (<digest>.wai-share.json) for `wai statement verify`; replacing a file waits for --yes",
|
||||
effect: Effect::WritesProject,
|
||||
stability: Stability::Preview,
|
||||
input: &[
|
||||
pos("inputs", Ty::Path, true, "A verification document without its statement: contract, contracts, runs and ledgers."),
|
||||
flag("party", "--party", Ty::String, true, "The party's key, 64 hexadecimal digits: the operator or an owner."),
|
||||
flag("start", "--start", Ty::Integer, true, "The period's start, microseconds since 1970."),
|
||||
flag("end", "--end", Ty::Integer, true, "The period's end, after its start."),
|
||||
flag("key", "--key", Ty::Path, true, "The operator key's file: 32 bytes, or 64 hexadecimal digits."),
|
||||
flag("out", "--out", Ty::Path, false, "The verification document's file (<digest>.wai-share.json)."),
|
||||
],
|
||||
output: Output::Json(S_SHARE_STATEMENT),
|
||||
receipt: true,
|
||||
needs: Needs::All(&["revenue_share"]),
|
||||
confirm: Some(Confirm::AnyFile),
|
||||
..OP
|
||||
},
|
||||
Op {
|
||||
id: "statement.verify",
|
||||
summary: "A revenue-share statement's verification document checked: every line recomputed from the public records, or the reason it is refused",
|
||||
cli: Some("statement verify"),
|
||||
usage: "statement verify <document.json> the outcome of a statement's verification document: each line, each split's
|
||||
division and each impression's attribution, recomputed; exit 1 with the reason on a refusal",
|
||||
stability: Stability::Preview,
|
||||
input: &[pos("document", Ty::Path, true, "The verification document: contract, contracts, statement, runs and ledgers.")],
|
||||
output: Output::Json(S_SHARE_OUTCOME),
|
||||
needs: Needs::All(&["revenue_share"]),
|
||||
..OP
|
||||
},
|
||||
// ─── the catalogue itself ───
|
||||
Op {
|
||||
id: "ops.list",
|
||||
|
|
@ -1547,6 +1628,7 @@ pub fn feature_on(feature: &str) -> Option<bool> {
|
|||
"mcp" => cfg!(feature = "mcp"),
|
||||
"app" => cfg!(feature = "app"),
|
||||
"fleet" => cfg!(feature = "fleet"),
|
||||
"revenue_share" => cfg!(feature = "revenue_share"),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,6 +239,7 @@ pub const CAP_DELIVERY_SETTLEMENT: &str = "wai.delivery.settlement";
|
|||
pub const CAP_DELIVERY_LEDGER: &str = "wai.delivery.ledger";
|
||||
pub const CAP_DELIVERY_AUDIT: &str = "wai.delivery.audit";
|
||||
pub const CAP_DELIVERY_FLEET: &str = "wai.delivery.fleet";
|
||||
pub const CAP_DELIVERY_REVENUE_SHARE: &str = "wai.delivery.revenue_share";
|
||||
pub const CAP_MEDIA_TAKEDOWN: &str = "wai.media.takedown";
|
||||
pub const CAP_LIVE_COPERFORMANCE: &str = "wai.live.coperformance";
|
||||
pub const CAP_MEDIA_INFLUENCE: &str = "wai.media.influence";
|
||||
|
|
@ -365,6 +366,7 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[
|
|||
(CAP_DELIVERY_LEDGER, S_INTERACTIVE, None, "2026-10-25"),
|
||||
(CAP_DELIVERY_AUDIT, S_INTERACTIVE, None, "2026-10-25"),
|
||||
(CAP_DELIVERY_FLEET, S_INTERACTIVE, None, "2026-10-27"),
|
||||
(CAP_DELIVERY_REVENUE_SHARE, S_INTERACTIVE, None, "2026-10-29"),
|
||||
(CAP_MEDIA_TAKEDOWN, S_INTERACTIVE, None, "2026-10-21"),
|
||||
(CAP_LIVE_COPERFORMANCE, S_INTERACTIVE, None, "2026-10-21"),
|
||||
(CAP_MEDIA_INFLUENCE, S_INTERACTIVE, None, "2026-10-21"),
|
||||
|
|
@ -458,6 +460,7 @@ const RECORDS: &[&str] = &[
|
|||
CAP_DELIVERY_LEDGER,
|
||||
CAP_DELIVERY_AUDIT,
|
||||
CAP_DELIVERY_FLEET,
|
||||
CAP_DELIVERY_REVENUE_SHARE,
|
||||
CAP_MEDIA_TAKEDOWN,
|
||||
CAP_LIVE_COPERFORMANCE,
|
||||
CAP_MEDIA_INFLUENCE,
|
||||
|
|
@ -656,6 +659,7 @@ const SETTLEMENT: &str = "extensions/settlement.md";
|
|||
const LEDGER: &str = "extensions/receipt-ledger.md";
|
||||
const STREAM_AUDIT: &str = "extensions/stream-audit.md";
|
||||
const FLEET: &str = "extensions/fleet.md";
|
||||
const REVENUE_SHARE: &str = "extensions/revenue-share.md";
|
||||
const BROWSER_SINK: &str = "extensions/browser-sink.md";
|
||||
const TAKEDOWN: &str = "extensions/takedown.md";
|
||||
const INFLUENCE: &str = "extensions/influence.md";
|
||||
|
|
@ -831,6 +835,8 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
|
|||
wire("WFR1", WireKind::Magic, FLEET, "§6.1", WireStatus::Defined),
|
||||
wire("wai-fleet-bundle", WireKind::DocumentKind, FLEET, "§7.1", WireStatus::Defined),
|
||||
wire(".wai-fleet.json", WireKind::Path, FLEET, "§7.1", WireStatus::Defined),
|
||||
wire("WRC1", WireKind::Magic, REVENUE_SHARE, "§3.1", WireStatus::Defined),
|
||||
wire("WRS1", WireKind::Magic, REVENUE_SHARE, "§7.1", WireStatus::Defined),
|
||||
wire("contentBinding", WireKind::FrameMetadata, BROWSER_SINK, "§7.1", WireStatus::Proposed),
|
||||
wire("wai-takedown-clocks", WireKind::DocumentKind, TAKEDOWN, "§4", WireStatus::Defined),
|
||||
wire("wai-takedown-policy", WireKind::DocumentKind, TAKEDOWN, "§5.1", WireStatus::Defined),
|
||||
|
|
|
|||
|
|
@ -732,6 +732,15 @@ pub mod settlement;
|
|||
/// cbindgen:ignore
|
||||
#[cfg(feature = "receipt_ledger")]
|
||||
pub mod receipt_ledger;
|
||||
// Revenue share (extensions/revenue-share.md): `wai.delivery.revenue_share` — a share contract an
|
||||
// operator and every owner it names sign (a channel, its carriers, each asset's split of integer
|
||||
// millionths, the rule for a break between programmes), and the statement of one party and one
|
||||
// period the operator signs, recomputed by a verifier from the ledger's verified invoices and the
|
||||
// channel's program-as-run. Rust-only, like the other record modules: its constants must not
|
||||
// become macros in include/wai.h.
|
||||
/// cbindgen:ignore
|
||||
#[cfg(feature = "revenue_share")]
|
||||
pub mod revenue_share;
|
||||
// Stream audit (extensions/stream-audit.md): `wai.delivery.audit` — a player's signed evidence of a
|
||||
// stream another service delivers over HTTP (acknowledgements by plain SHA-256, presentation claims for
|
||||
// marked ads, the session record), the bundle a collector verifies, and the channel report it signs
|
||||
|
|
|
|||
1559
wai/wai-rs/src/revenue_share.rs
Normal file
1559
wai/wai-rs/src/revenue_share.rs
Normal file
File diff suppressed because it is too large
Load diff
482
wai/wai-rs/src/revenue_share/corpus.rs
Normal file
482
wai/wai-rs/src/revenue_share/corpus.rs
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
//! The revenue-share corpus (`live-conformance/share/`, extensions/revenue-share.md §12).
|
||||
//!
|
||||
//! Its evidence comes from the ledger corpus (`live-conformance/ledger/`), each source taken whole
|
||||
//! as that corpus states it: a room's break-form receipts under a policy that requires claims and
|
||||
//! under one that does not, and three streams stitched by others. The tariffs, the invoices, the
|
||||
//! channel's runs, the contracts and the statements are written here from fixed seeds. [`generate`]
|
||||
//! reads the ledger corpus from a `wai/` directory; [`verify`] checks every vector against the
|
||||
//! outcome it states.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::energy_class::EnergyProvenance;
|
||||
use crate::quantum_ops::GrantRef;
|
||||
use crate::receipt_ledger::{Invoice, Tariff};
|
||||
use crate::wai_video_channel::ScheduleEntry;
|
||||
|
||||
/// 2026-10-01, 2026-11-01, 2026-12-01, 2027-01-01, 2027-03-01, 2027-04-01 (00:00:00Z).
|
||||
const OCT: u64 = 1_790_812_800_000_000;
|
||||
const NOV: u64 = 1_793_491_200_000_000;
|
||||
const DEC: u64 = 1_796_083_200_000_000;
|
||||
const JAN: u64 = 1_798_761_600_000_000;
|
||||
const MAR: u64 = 1_803_859_200_000_000;
|
||||
const APR: u64 = 1_806_537_600_000_000;
|
||||
/// 2026-10-16T00:00:00Z: the ledger corpus's rooms' `t0`, and the first invoice's day.
|
||||
const DAY16: u64 = 1_792_108_800_000_000;
|
||||
const DAY_US: u64 = 86_400_000_000;
|
||||
/// 2026-10-20 and 2026-10-25, whose noon the stitched streams' markers name.
|
||||
const DAY20: u64 = DAY16 + 4 * DAY_US;
|
||||
const DAY25: u64 = DAY16 + 9 * DAY_US;
|
||||
/// Per presented and per billable impression, in millionths of a minor unit.
|
||||
const PRICES: [u64; 2] = [1501, 903];
|
||||
const PRICES_2: [u64; 2] = [2000, 1000];
|
||||
/// The ledger corpus's sources this corpus shares.
|
||||
const SOURCES: [&str; 5] = ["ads_live_break", "ads_presented", "stitched_hls", "stitched_form1", "stitched_squeezeback"];
|
||||
const CHANNEL: &str = "fast-7";
|
||||
|
||||
/// A key of the corpus, from its fixed seed: `operator`, `owner-a`, `owner-b`, `channel`, `buyer`,
|
||||
/// `buyer-2`, `stranger`. Test keys, never a deployment's.
|
||||
pub fn key(label: &str) -> SigningKey {
|
||||
SigningKey::from_bytes(&sha256(&[b"wai:share-corpus\x01", label.as_bytes()]))
|
||||
}
|
||||
|
||||
fn pk(label: &str) -> [u8; 32] {
|
||||
key(label).verifying_key().to_bytes()
|
||||
}
|
||||
|
||||
fn read_json(wai: &Path, rel: &str) -> Result<Value, String> {
|
||||
let t = std::fs::read_to_string(wai.join(rel)).map_err(|e| format!("{rel}: {e}"))?;
|
||||
serde_json::from_str(&t).map_err(|e| format!("{rel}: {e}"))
|
||||
}
|
||||
|
||||
fn named<'a>(list: &'a Value, name: &str) -> Result<&'a Value, String> {
|
||||
list.as_array().and_then(|l| l.iter().find(|v| v["name"] == name)).ok_or_else(|| format!("no ledger vector {name}"))
|
||||
}
|
||||
|
||||
/// The ad deciders and stitchers a ledger vector's tariff bills (receipt-ledger §4.4, kind 4).
|
||||
fn ad_scope(ledger_doc: &Value, vector: &str) -> Result<Vec<[u8; 32]>, String> {
|
||||
let t = named(&ledger_doc["vectors"], vector)?["tariff"].as_str().and_then(unhex).ok_or("a tariff")?;
|
||||
let t = Tariff::parse(&t).map_err(|e| format!("{vector}'s tariff: {e:?}"))?;
|
||||
Ok(t.scope.iter().filter(|(k, _)| *k == 4).map(|(_, id)| *id).collect())
|
||||
}
|
||||
|
||||
/// A tariff of the operator's with a buyer, for the contract's period.
|
||||
fn tariff(buyer: &str, currency: [u8; 3], ads: [u64; 2], scope: &[[u8; 32]]) -> Tariff {
|
||||
let mut scope: Vec<(u8, [u8; 32])> = scope.iter().map(|id| (4, *id)).collect();
|
||||
scope.sort();
|
||||
scope.dedup();
|
||||
Tariff {
|
||||
seller: pk("operator"),
|
||||
buyer: pk(buyer),
|
||||
start: OCT,
|
||||
end: JAN,
|
||||
currency,
|
||||
room: [0; ledger::ROOM_PRICES],
|
||||
delivery: [0; 4],
|
||||
package: [0; 3],
|
||||
ads,
|
||||
scope,
|
||||
seller_sig: [0; 64],
|
||||
buyer_sig: [0; 64],
|
||||
}
|
||||
.sign(&key("operator"), &key(buyer))
|
||||
}
|
||||
|
||||
/// A ledger document as the corpus holds it: its sources by name.
|
||||
fn held(t: &Tariff, i: &Invoice, previous: Option<&Invoice>, sources: &[&str]) -> Value {
|
||||
json!({"tariff": hex(&t.to_bytes()), "invoice": hex(&i.to_bytes()), "previous": previous.map(|p| hex(&p.to_bytes())), "sources": sources})
|
||||
}
|
||||
|
||||
fn invoice(pool: &BTreeMap<String, Value>, t: &Tariff, previous: Option<&Invoice>, start: u64, end: u64, sources: &[&str]) -> Result<Invoice, String> {
|
||||
let s: Vec<Value> = sources.iter().map(|n| pool[*n].clone()).collect();
|
||||
ledger::make_invoice(&key("operator"), t, previous, start, end, &s).map_err(|e| format!("an invoice of {sources:?}: {e:?}"))
|
||||
}
|
||||
|
||||
fn entry(asset: &str, start_ms: u64, duration_ms: u64, kind: EntryKind) -> ScheduleEntry {
|
||||
ScheduleEntry { asset_id: asset.into(), start_ms, duration_ms, kind }
|
||||
}
|
||||
|
||||
/// A run of `channel`, signed by the channel signer.
|
||||
fn run(channel: &str, schedule: Vec<ScheduleEntry>, window: (u64, u64), joules: u64, class: Option<EnergyProvenance>, parent: Option<[u8; 32]>) -> ChannelReceipt {
|
||||
ChannelReceipt::seal(&key("channel"), "did:key:channel-signer", channel, schedule, window.0, window.1, joules, class, GrantRef::unbounded("video.channel"), parent)
|
||||
}
|
||||
|
||||
/// The channel's two windows: the 16th's first five minutes, and the 25th's noon half hour.
|
||||
fn windows() -> (ChannelReceipt, ChannelReceipt, ChannelReceipt) {
|
||||
let a = DAY16 / 1000;
|
||||
let r1 = run(
|
||||
CHANNEL,
|
||||
vec![
|
||||
entry("a/ep-101", a, 90_000, EntryKind::Program),
|
||||
entry("slate-15", a + 90_000, 15_000, EntryKind::Ad),
|
||||
entry("b/doc-7", a + 105_000, 195_000, EntryKind::Program),
|
||||
],
|
||||
(a, a + 300_000),
|
||||
4_000,
|
||||
Some(EnergyProvenance::ModelBased),
|
||||
None,
|
||||
);
|
||||
let noon = DAY25 / 1000 + 43_200_000;
|
||||
let second = |last: &str| {
|
||||
vec![
|
||||
entry("a/ep-102", noon - 120_000, 250_000, EntryKind::Program),
|
||||
entry("mid-1", noon + 130_000, 30_000, EntryKind::Ad),
|
||||
entry(last, noon + 160_000, 1_640_000, EntryKind::Program),
|
||||
]
|
||||
};
|
||||
let r2 = run(CHANNEL, second("b/doc-8"), (noon, noon + 1_800_000), 0, None, Some(r1.receipt_hash()));
|
||||
let r2_unmapped = run(CHANNEL, second("c/film-9"), (noon, noon + 1_800_000), 0, None, Some(r1.receipt_hash()));
|
||||
(r1, r2, r2_unmapped)
|
||||
}
|
||||
|
||||
/// The contract's terms: two owners, a split each and a between split, under `rule`.
|
||||
fn terms_of(room: [u8; 32], stitchers: &[[u8; 32]], rule_: u8, period: (u64, u64)) -> Contract {
|
||||
let mut owners = vec![pk("owner-a"), pk("owner-b")];
|
||||
owners.sort();
|
||||
let ia = owners.iter().position(|k| *k == pk("owner-a")).expect("listed") as u8;
|
||||
let ib = 1 - ia;
|
||||
let mut carriers: Vec<(u8, [u8; 32])> = vec![(carrier::ROOM, room)];
|
||||
carriers.extend(stitchers.iter().map(|s| (carrier::STITCHER, *s)));
|
||||
carriers.sort();
|
||||
let mut halves = vec![(ia, 500_000), (ib, 500_000)];
|
||||
halves.sort();
|
||||
Contract {
|
||||
operator: pk("operator"),
|
||||
start: period.0,
|
||||
end: period.1,
|
||||
currency: *b"EUR",
|
||||
channel: CHANNEL.as_bytes().to_vec(),
|
||||
channel_signer: pk("channel"),
|
||||
rule: rule_,
|
||||
between: 2,
|
||||
carriers,
|
||||
owners,
|
||||
splits: vec![
|
||||
Split { operator_ppm: 666_667, shares: vec![(ia, 333_333)] },
|
||||
Split { operator_ppm: 400_000, shares: vec![(ib, 600_000)] },
|
||||
Split { operator_ppm: 0, shares: halves },
|
||||
],
|
||||
assets: ["a/ep-101", "a/ep-102", "a/ep-103", "b/doc-7", "b/doc-8"].iter().map(|a| (a.as_bytes().to_vec(), if a.starts_with('a') { 0 } else { 1 })).collect(),
|
||||
operator_sig: [0; 64],
|
||||
countersigs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn in_force(c: Contract) -> Contract {
|
||||
c.offer(&key("operator")).countersign(&key("owner-a")).expect("an owner").countersign(&key("owner-b")).expect("an owner")
|
||||
}
|
||||
|
||||
/// A statement changed and signed again by the operator.
|
||||
fn resealed(s: &Statement, f: impl FnOnce(&mut Statement)) -> Vec<u8> {
|
||||
let mut s = s.clone();
|
||||
f(&mut s);
|
||||
s.sign(&key("operator")).to_bytes()
|
||||
}
|
||||
|
||||
/// One vector: what it names, by name.
|
||||
struct Vector {
|
||||
name: &'static str,
|
||||
about: &'static str,
|
||||
contract: &'static str,
|
||||
contracts: Vec<&'static str>,
|
||||
statement: Vec<u8>,
|
||||
runs: Option<Vec<&'static str>>,
|
||||
ledgers: Vec<&'static str>,
|
||||
}
|
||||
|
||||
/// The pools the vectors name.
|
||||
struct Pools {
|
||||
sources: BTreeMap<String, Value>,
|
||||
ledgers: BTreeMap<String, Value>,
|
||||
runs: BTreeMap<String, Value>,
|
||||
contracts: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
impl Pools {
|
||||
fn ledger(&self, name: &str) -> Value {
|
||||
let l = &self.ledgers[name];
|
||||
let sources: Vec<Value> = l["sources"].as_array().expect("names").iter().map(|n| self.sources[n.as_str().expect("a name")].clone()).collect();
|
||||
json!({"tariff": l["tariff"], "invoice": l["invoice"], "previous": l["previous"], "sources": sources})
|
||||
}
|
||||
|
||||
/// The verification document of named parts, with a statement or without one.
|
||||
fn document(&self, contract: &str, contracts: &[&str], statement: Option<&[u8]>, runs: Option<&[&str]>, ledgers: &[&str]) -> Value {
|
||||
let mut d = Map::new();
|
||||
d.insert("contract".into(), self.contracts[contract].clone());
|
||||
d.insert("contracts".into(), Value::Array(contracts.iter().map(|n| self.contracts[*n].clone()).collect()));
|
||||
if let Some(s) = statement {
|
||||
d.insert("statement".into(), json!(hex(s)));
|
||||
}
|
||||
d.insert("runs".into(), runs.map_or(Value::Null, |r| Value::Array(r.iter().map(|n| self.runs[*n].clone()).collect())));
|
||||
d.insert("ledgers".into(), Value::Array(ledgers.iter().map(|n| self.ledger(n)).collect()));
|
||||
Value::Object(d)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the corpus, from fixed seeds and the ledger corpus under `wai`.
|
||||
pub fn generate(wai: &Path) -> Result<String, String> {
|
||||
let ledger_doc = read_json(wai, "live-conformance/ledger/vectors.json")?;
|
||||
let mut sources = BTreeMap::new();
|
||||
for n in SOURCES {
|
||||
sources.insert(n.to_string(), ledger_doc["sources"].get(n).cloned().ok_or_else(|| format!("the ledger corpus has no source {n}"))?);
|
||||
}
|
||||
let mut scope = ad_scope(&ledger_doc, "a_day")?;
|
||||
let stitchers = ad_scope(&ledger_doc, "streams_stitched_by_others")?;
|
||||
scope.extend(&stitchers);
|
||||
let room = match ledger::verify_source(&sources["ads_presented"], &[]).map_err(|e| format!("ads_presented: {e:?}"))? {
|
||||
Facts::Ads(a) => a.room,
|
||||
_ => return Err("ads_presented is an ads source".into()),
|
||||
};
|
||||
|
||||
// The invoices: a chain of two under the first buyer's tariff, and three under a second's.
|
||||
let t1 = tariff("buyer", *b"EUR", PRICES, &scope);
|
||||
let t2 = tariff("buyer-2", *b"EUR", PRICES_2, &scope);
|
||||
let i1 = invoice(&sources, &t1, None, DAY16, DAY16 + DAY_US, &["ads_live_break", "ads_presented"])?;
|
||||
let stitched = ["stitched_hls", "stitched_form1", "stitched_squeezeback"];
|
||||
let i2 = invoice(&sources, &t1, Some(&i1), DAY25, DAY25 + DAY_US, &stitched)?;
|
||||
let i_dup = invoice(&sources, &t2, None, DAY16, DAY16 + DAY_US, &["ads_live_break"])?;
|
||||
let i_early = invoice(&sources, &t2, None, DAY20, DAY20 + DAY_US, &stitched)?;
|
||||
let mut ledgers = BTreeMap::new();
|
||||
ledgers.insert("day_16".to_string(), held(&t1, &i1, None, &["ads_live_break", "ads_presented"]));
|
||||
ledgers.insert("day_25".to_string(), held(&t1, &i2, Some(&i1), &stitched));
|
||||
ledgers.insert("day_16_rebilled".to_string(), held(&t2, &i_dup, None, &["ads_live_break"]));
|
||||
ledgers.insert("day_20".to_string(), held(&t2, &i_early, None, &stitched));
|
||||
let mut flipped = ledgers["day_16"].clone();
|
||||
let mut ib = i1.to_bytes();
|
||||
let last = ib.len() - 1;
|
||||
ib[last] ^= 0x01;
|
||||
flipped["invoice"] = json!(hex(&ib));
|
||||
ledgers.insert("day_16_invoice_flipped".to_string(), flipped);
|
||||
let foreign = named(&ledger_doc["vectors"], "streams_stitched_by_others")?;
|
||||
ledgers.insert("another_seller".to_string(), json!({"tariff": foreign["tariff"], "invoice": foreign["invoice"], "previous": foreign["previous"], "sources": foreign["sources"]}));
|
||||
|
||||
// The runs.
|
||||
let (r1, r2, r2_unmapped) = windows();
|
||||
let mut runs = BTreeMap::new();
|
||||
runs.insert("oct_16".to_string(), r1.to_json());
|
||||
runs.insert("oct_25".to_string(), r2.to_json());
|
||||
runs.insert("oct_25_unmapped".to_string(), r2_unmapped.to_json());
|
||||
let mut sigless = r1.to_json();
|
||||
sigless.as_object_mut().expect("an object").remove("sig");
|
||||
runs.insert("oct_16_no_sig".to_string(), sigless);
|
||||
let mut tampered = r1.clone();
|
||||
tampered.aired = vec![0];
|
||||
tampered.sig = key("channel").sign(&tampered.payload()).to_bytes();
|
||||
runs.insert("oct_16_aired_misstated".to_string(), tampered.to_json());
|
||||
let other = ChannelReceipt::seal(&key("channel"), "did:key:channel-signer", "fast-8", r1.schedule.clone(), r1.window_start_ms, r1.window_end_ms, 0, None, GrantRef::unbounded("video.channel"), None);
|
||||
runs.insert("fast_8".to_string(), other.to_json());
|
||||
let late = run(CHANNEL, r1.schedule.clone(), (r1.window_start_ms + 240_000, r1.window_start_ms + 600_000), 0, None, None);
|
||||
runs.insert("oct_16_overlapping".to_string(), late.to_json());
|
||||
|
||||
// The contracts.
|
||||
let c = in_force(terms_of(room, &stitchers, rule::PRECEDING, (OCT, JAN)));
|
||||
let c_following = in_force(terms_of(room, &stitchers, rule::FOLLOWING, (OCT, JAN)));
|
||||
let c_between = in_force(terms_of(room, &stitchers, rule::BETWEEN, (OCT, JAN)));
|
||||
let c_next = in_force(terms_of(room, &stitchers, rule::PRECEDING, (JAN, APR)));
|
||||
let c_overlap = in_force(terms_of(room, &stitchers, rule::PRECEDING, (DEC, MAR)));
|
||||
let ib_owner = c.owners.iter().position(|k| *k == pk("owner-b")).expect("listed");
|
||||
let ia_owner = 1 - ib_owner;
|
||||
let c_offered = terms_of(room, &stitchers, rule::PRECEDING, (OCT, JAN)).offer(&key("operator")).countersign(&key("owner-a")).expect("an owner");
|
||||
let mut c_owner_sig = c.clone();
|
||||
c_owner_sig.countersigs[ia_owner][0] ^= 0x01;
|
||||
let mut unwhole = terms_of(room, &stitchers, rule::PRECEDING, (OCT, JAN));
|
||||
unwhole.splits[0].operator_ppm -= 1;
|
||||
let unwhole = in_force(unwhole);
|
||||
let mut unordered = terms_of(room, &stitchers, rule::PRECEDING, (OCT, JAN));
|
||||
unordered.assets.reverse();
|
||||
let unordered = in_force(unordered);
|
||||
let mut small = c.clone();
|
||||
small.operator = crate::sig::IDENTITY;
|
||||
let cb = c.to_bytes();
|
||||
let mut contracts = BTreeMap::new();
|
||||
let mut put = |n: &str, b: Vec<u8>| {
|
||||
contracts.insert(n.to_string(), json!(hex(&b)));
|
||||
};
|
||||
put("preceding", cb.clone());
|
||||
put("following", c_following.to_bytes());
|
||||
put("between", c_between.to_bytes());
|
||||
put("next_quarter", c_next.to_bytes());
|
||||
put("overlapping", c_overlap.to_bytes());
|
||||
put("offered", c_offered.to_bytes());
|
||||
put("owner_signature_flipped", c_owner_sig.to_bytes());
|
||||
put("split_short", unwhole.to_bytes());
|
||||
put("assets_unordered", unordered.to_bytes());
|
||||
put("operator_key_small_order", small.to_bytes());
|
||||
put("truncated", cb[..3].to_vec());
|
||||
put("magic", [b"WRC2".as_slice(), &cb[4..]].concat());
|
||||
put("version_2", [&cb[..4], &[2u8][..], &cb[5..]].concat());
|
||||
put("trailing_byte", [cb.as_slice(), &[0u8][..]].concat());
|
||||
let mut flip = cb.clone();
|
||||
flip[c.body().len()] ^= 0x01;
|
||||
put("operator_signature_flipped", flip);
|
||||
|
||||
let pools = Pools { sources, ledgers, runs, contracts };
|
||||
|
||||
// The accepted statements.
|
||||
let main_runs = ["oct_16", "oct_25"];
|
||||
let main_ledgers = ["day_16", "day_25"];
|
||||
let statement = |contract: &str, party: [u8; 32], ledgers_: &[&str]| -> Result<Statement, String> {
|
||||
let inputs = pools.document(contract, &["next_quarter", contract], None, Some(&main_runs[..]), ledgers_);
|
||||
super::create(&inputs, &key("operator"), party, OCT, NOV).map(|(s, _)| s).map_err(|e| format!("the statement of {contract}: {e:?}"))
|
||||
};
|
||||
let s_a = statement("preceding", pk("owner-a"), &main_ledgers)?;
|
||||
let s_b = statement("preceding", pk("owner-b"), &main_ledgers)?;
|
||||
let s_o = statement("preceding", pk("operator"), &main_ledgers)?;
|
||||
let s_b_following = statement("following", pk("owner-b"), &main_ledgers)?;
|
||||
let s_a_between = statement("between", pk("owner-a"), &main_ledgers)?;
|
||||
let s_a_nothing = statement("preceding", pk("owner-a"), &[])?;
|
||||
let sa = s_a.to_bytes();
|
||||
|
||||
let main = |name: &'static str, about: &'static str, statement: Vec<u8>| Vector {
|
||||
name,
|
||||
about,
|
||||
contract: "preceding",
|
||||
contracts: vec!["next_quarter", "preceding"],
|
||||
statement,
|
||||
runs: Some(main_runs.to_vec()),
|
||||
ledgers: main_ledgers.to_vec(),
|
||||
};
|
||||
let with_contract = |name: &'static str, about: &'static str, contract: &'static str| Vector { contract, contracts: vec![], ..main(name, about, sa.clone()) };
|
||||
let with_runs = |name: &'static str, about: &'static str, runs: Vec<&'static str>| Vector { runs: Some(runs), ..main(name, about, sa.clone()) };
|
||||
let with_ledgers = |name: &'static str, about: &'static str, ledgers: Vec<&'static str>, statement: Vec<u8>| Vector { ledgers, ..main(name, about, statement) };
|
||||
let line_b = s_b.lines[0];
|
||||
|
||||
let vectors = vec![
|
||||
// Accepted.
|
||||
main("owner_a_october", "owner A's October: the beacon break inside its programme on the 16th, its programme's stitched ads on the 25th and the ad slot after it by the rule preceding, and its half of the unplaced presentations", sa.clone()),
|
||||
main("owner_b_october", "owner B's October: the presented break inside its programme on the 16th, the stitched ad inside its programme on the 25th, and its half of the unplaced presentations", s_b.to_bytes()),
|
||||
main("operator_october", "the operator's October: its part of each owner's split, and no line of the between split, in which it holds no share", s_o.to_bytes()),
|
||||
Vector { contract: "following", contracts: vec![], ..main("owner_b_following", "owner B's October under the rule following: the ad slot on the 25th is its programme's", s_b_following.to_bytes()) },
|
||||
Vector { contract: "between", contracts: vec![], ..main("owner_a_between", "owner A's October under the rule between: the ad slot on the 25th goes to the between split", s_a_between.to_bytes()) },
|
||||
with_ledgers("owner_a_nothing_billed", "a statement of a period with no invoice: no line, a total of zero", vec![], s_a_nothing.to_bytes()),
|
||||
// The document and the contract.
|
||||
Vector { runs: None, ..main("document_malformed", "a verification document whose runs are not a list", sa.clone()) },
|
||||
with_contract("contract_truncated", "a contract of three bytes", "truncated"),
|
||||
with_contract("contract_magic", "a contract whose magic is WRC2", "magic"),
|
||||
with_contract("contract_version_2", "a contract of version 2", "version_2"),
|
||||
with_contract("contract_trailing_byte", "a contract with a byte after its last countersignature", "trailing_byte"),
|
||||
with_contract("contract_assets_unordered", "a contract whose assets are not in ascending order", "assets_unordered"),
|
||||
with_contract("contract_split_short", "a contract one of whose splits sums to one millionth short of the whole", "split_short"),
|
||||
with_contract("contract_operator_key_small_order", "a contract whose operator key is of small order", "operator_key_small_order"),
|
||||
with_contract("contract_operator_signature", "a contract whose operator signature has a byte flipped", "operator_signature_flipped"),
|
||||
with_contract("contract_offered", "a contract owner B has not countersigned", "offered"),
|
||||
with_contract("contract_owner_signature", "a contract whose owner A countersignature has a byte flipped", "owner_signature_flipped"),
|
||||
Vector { contracts: vec!["next_quarter", "overlapping"], ..main("contract_overlap", "another contract of the channel whose period overlaps the contract's last month", sa.clone()) },
|
||||
Vector { contracts: vec!["offered"], ..main("other_contract_offered", "another contract given that is not in force", sa.clone()) },
|
||||
// The statement.
|
||||
main("statement_truncated", "a statement of ten bytes", sa[..10].to_vec()),
|
||||
main("statement_lines_unordered", "a statement whose lines are not in ascending order", resealed(&s_a, |s| s.lines.reverse())),
|
||||
main("statement_of_another_contract", "a statement naming the contract under the rule following", resealed(&s_a, |s| s.contract = c_following.digest())),
|
||||
main("statement_of_a_stranger", "a statement for a key the contract does not name", resealed(&s_a, |s| s.party = pk("stranger"))),
|
||||
main("statement_past_the_contract", "a statement whose period ends a day after the contract's", resealed(&s_a, |s| s.end = JAN + DAY_US)),
|
||||
main("statement_signature", "a statement whose signature has a byte flipped", {
|
||||
let mut b = sa.clone();
|
||||
let n = b.len();
|
||||
b[n - 40] ^= 0x01;
|
||||
b
|
||||
}),
|
||||
// The runs.
|
||||
with_runs("run_without_signature", "a run whose carriage has no sig", vec!["oct_16_no_sig", "oct_25"]),
|
||||
with_runs("run_aired_misstated", "a run whose aired set the window does not give, signed again", vec!["oct_16_aired_misstated", "oct_25"]),
|
||||
with_runs("run_of_another_channel", "a run of another channel, by the same signer", vec!["oct_16", "fast_8"]),
|
||||
with_runs("runs_overlapping", "a third run whose window overlaps the first's", vec!["oct_16", "oct_25", "oct_16_overlapping"]),
|
||||
// The ledger documents.
|
||||
with_ledgers("ledger_refused", "a ledger document whose invoice signature has a byte flipped, refused by the receipt ledger at its place", vec!["day_16_invoice_flipped", "day_25"], sa.clone()),
|
||||
with_ledgers("invoice_of_another_seller", "a ledger document of the ledger corpus, whose seller is not the operator", vec!["day_16", "day_25", "another_seller"], sa.clone()),
|
||||
main("invoice_outside_the_period", "a statement of the 1st to the 20th, given the invoice of the 25th", resealed(&s_a, |s| s.end = DAY20)),
|
||||
with_ledgers("impression_billed_twice", "a second buyer's invoice billing the beacon break of the 16th again", vec!["day_16", "day_25", "day_16_rebilled"], sa.clone()),
|
||||
// The impressions.
|
||||
with_ledgers("impression_outside_the_period", "a statement of the 20th whose invoice of that day bills the stitched presentations of the 25th", vec!["day_20"], resealed(&s_a, |s| (s.start, s.end) = (DAY20, DAY20 + DAY_US))),
|
||||
with_runs("impression_outside_every_window", "the stitched presentations of the 25th with only the run of the 16th", vec!["oct_16"]),
|
||||
with_runs("asset_unmapped", "a run of the 25th whose last programme the contract does not map", vec!["oct_16", "oct_25_unmapped"]),
|
||||
// The figures.
|
||||
main("line_of_another_split", "owner A's statement with a line of owner B's split", resealed(&s_a, |s| {
|
||||
s.lines.insert(2, Line { ppm: 1, amount: 0, ..line_b });
|
||||
})),
|
||||
main("line_impressions_raised", "a line stating one impression more than its split and class give", resealed(&s_a, |s| s.lines[0].impressions += 1)),
|
||||
main("line_citations_changed", "a line whose citation digest has a byte changed", resealed(&s_a, |s| s.lines[0].cites[0] ^= 0x01)),
|
||||
main("line_amount_raised", "a line whose amount is one more than its part, and the total with it", resealed(&s_a, |s| {
|
||||
s.lines[0].amount += 1;
|
||||
s.total += 1;
|
||||
})),
|
||||
main("line_missing", "a statement that leaves its last line out, and its amount from the total", resealed(&s_a, |s| {
|
||||
let l = s.lines.pop().expect("a line");
|
||||
s.total -= l.amount;
|
||||
})),
|
||||
main("runs_listed_short", "a statement listing one of the two runs it is given", resealed(&s_a, |s| {
|
||||
s.runs.retain(|r| *r != r2.receipt_hash());
|
||||
})),
|
||||
];
|
||||
|
||||
let mut out = Vec::with_capacity(vectors.len());
|
||||
for v in &vectors {
|
||||
let doc = pools.document(v.contract, &v.contracts, Some(&v.statement), v.runs.as_deref(), &v.ledgers);
|
||||
let outcome = outcome_json(&super::verify(&doc));
|
||||
out.push(json!({
|
||||
"name": v.name, "about": v.about, "contract": v.contract, "contracts": v.contracts, "statement": hex(&v.statement),
|
||||
"runs": v.runs, "ledgers": v.ledgers, "outcome": outcome,
|
||||
}));
|
||||
}
|
||||
let keys = json!({
|
||||
"operator": hex(&pk("operator")), "owner_a": hex(&pk("owner-a")), "owner_b": hex(&pk("owner-b")), "channel_signer": hex(&pk("channel")),
|
||||
"buyer": hex(&pk("buyer")), "buyer_2": hex(&pk("buyer-2")), "stranger": hex(&pk("stranger")),
|
||||
});
|
||||
let to_map = |m: BTreeMap<String, Value>| Value::Object(m.into_iter().collect());
|
||||
let doc = json!({
|
||||
"corpus": CAPABILITY,
|
||||
"extension": "extensions/revenue-share.md",
|
||||
"keys": keys,
|
||||
"sources": to_map(pools.sources),
|
||||
"ledgers": to_map(pools.ledgers),
|
||||
"runs": to_map(pools.runs),
|
||||
"contracts": to_map(pools.contracts),
|
||||
"vectors": out,
|
||||
});
|
||||
Ok(serde_json::to_string_pretty(&doc).expect("JSON") + "\n")
|
||||
}
|
||||
|
||||
/// A vector of the corpus as a verification document (§8.1), its parts resolved by name.
|
||||
pub fn document(doc: &Value, v: &Value) -> Option<Value> {
|
||||
let ledger = |n: &Value| -> Option<Value> {
|
||||
let l = doc["ledgers"].get(n.as_str()?)?;
|
||||
let sources: Vec<Value> = l["sources"].as_array()?.iter().map(|s| doc["sources"].get(s.as_str()?).cloned()).collect::<Option<_>>()?;
|
||||
Some(json!({"tariff": l["tariff"], "invoice": l["invoice"], "previous": l["previous"], "sources": sources}))
|
||||
};
|
||||
let mut d = Map::new();
|
||||
d.insert("contract".into(), doc["contracts"].get(v["contract"].as_str()?)?.clone());
|
||||
d.insert("contracts".into(), Value::Array(v["contracts"].as_array()?.iter().map(|n| doc["contracts"].get(n.as_str()?).cloned()).collect::<Option<_>>()?));
|
||||
d.insert("statement".into(), v["statement"].clone());
|
||||
let runs = match &v["runs"] {
|
||||
Value::Null => Value::Null,
|
||||
r => Value::Array(r.as_array()?.iter().map(|n| doc["runs"].get(n.as_str()?).cloned()).collect::<Option<_>>()?),
|
||||
};
|
||||
d.insert("runs".into(), runs);
|
||||
d.insert("ledgers".into(), Value::Array(v["ledgers"].as_array()?.iter().map(ledger).collect::<Option<_>>()?));
|
||||
Some(Value::Object(d))
|
||||
}
|
||||
|
||||
/// The failures: every vector whose stated outcome is not the one [`super::verify`] gives.
|
||||
pub fn verify(text: &str) -> Vec<String> {
|
||||
let doc: Value = match serde_json::from_str(text) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return vec![format!("vectors.json: {e}")],
|
||||
};
|
||||
let mut fails = Vec::new();
|
||||
for v in doc["vectors"].as_array().into_iter().flatten() {
|
||||
let name = v["name"].as_str().unwrap_or("?");
|
||||
let Some(d) = document(&doc, v) else {
|
||||
fails.push(format!("{name}: the vector does not read"));
|
||||
continue;
|
||||
};
|
||||
let got = outcome_json(&super::verify(&d));
|
||||
if got != v["outcome"] {
|
||||
fails.push(format!("{name}: the outcome is {got}, the corpus states {}", v["outcome"]));
|
||||
}
|
||||
}
|
||||
fails
|
||||
}
|
||||
|
|
@ -265,6 +265,216 @@ impl ChannelReceipt {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// The JSON carriage (extensions/revenue-share.md §4): the receipt's fields written out for a
|
||||
// verifier that is not this crate. The signed bytes are those of `seal` and `signing_payload`,
|
||||
// unchanged; the carriage only writes them out and reads them back.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// The members every carriage has (revenue-share §4.1); a class with evidence adds its own.
|
||||
const CARRIAGE_MEMBERS: [&str; 14] = [
|
||||
"channel_id",
|
||||
"schedule_root",
|
||||
"schedule",
|
||||
"window_start_ms",
|
||||
"window_end_ms",
|
||||
"aired",
|
||||
"work_schedule_ops",
|
||||
"joules_micro",
|
||||
"energy_provenance",
|
||||
"grant",
|
||||
"parent_receipt_hash",
|
||||
"signer_pubkey",
|
||||
"signer_id",
|
||||
"sig",
|
||||
];
|
||||
|
||||
impl EntryKind {
|
||||
/// Its name in the carriage.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
EntryKind::Program => "program",
|
||||
EntryKind::Ad => "ad",
|
||||
EntryKind::Filler => "filler",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(s: &str) -> Option<EntryKind> {
|
||||
match s {
|
||||
"program" => Some(EntryKind::Program),
|
||||
"ad" => Some(EntryKind::Ad),
|
||||
"filler" => Some(EntryKind::Filler),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Lowercase hexadecimal of exactly `N` bytes.
|
||||
fn unhex<const N: usize>(v: &serde_json::Value) -> Option<[u8; N]> {
|
||||
let s = v.as_str()?;
|
||||
if s.len() != 2 * N || !s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; N];
|
||||
for (i, o) in out.iter_mut().enumerate() {
|
||||
*o = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
impl ChannelReceipt {
|
||||
/// The signing payload, as [`ChannelReceipt::verify`] checks it and revenue-share §4.2
|
||||
/// writes it out.
|
||||
pub fn payload(&self) -> Vec<u8> {
|
||||
self.signing_payload()
|
||||
}
|
||||
|
||||
/// The JSON carriage (revenue-share §4.1): every field, in that order. It is the one JSON
|
||||
/// form of a channel receipt: a record or tool that carries a receipt as JSON uses this one,
|
||||
/// and [`ChannelReceipt::from_json`] reads it back.
|
||||
pub fn to_json(&self) -> serde_json::Value {
|
||||
use crate::energy_class::EnergyProvenance as P;
|
||||
use serde_json::{Map, Value, json};
|
||||
let mut m = Map::new();
|
||||
m.insert("channel_id".into(), json!(self.channel_id));
|
||||
m.insert("schedule_root".into(), json!(hex(&self.schedule_root)));
|
||||
let entries: Vec<Value> = self
|
||||
.schedule
|
||||
.iter()
|
||||
.map(|e| json!({"asset_id": e.asset_id, "start_ms": e.start_ms, "duration_ms": e.duration_ms, "kind": e.kind.name()}))
|
||||
.collect();
|
||||
m.insert("schedule".into(), Value::Array(entries));
|
||||
m.insert("window_start_ms".into(), json!(self.window_start_ms));
|
||||
m.insert("window_end_ms".into(), json!(self.window_end_ms));
|
||||
m.insert("aired".into(), json!(self.aired));
|
||||
m.insert("work_schedule_ops".into(), json!(self.work_schedule_ops));
|
||||
m.insert("joules_micro".into(), json!(self.joules_micro));
|
||||
m.insert("energy_provenance".into(), self.energy_provenance.as_ref().map_or(Value::Null, |p| json!(p.label())));
|
||||
match &self.energy_provenance {
|
||||
Some(P::OnChipCounter { uncertainty, filtering }) => {
|
||||
m.insert("energy_uncertainty".into(), json!({"relative_ppm": uncertainty.relative_ppm, "window_us": uncertainty.window_us}));
|
||||
m.insert("energy_counter_filtering".into(), json!(filtering.label()));
|
||||
}
|
||||
Some(P::CalibratedInstrument { uncertainty, calibration_ref }) => {
|
||||
m.insert("energy_uncertainty".into(), json!({"relative_ppm": uncertainty.relative_ppm, "window_us": uncertainty.window_us}));
|
||||
m.insert("energy_calibration_ref".into(), json!(calibration_ref));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
m.insert(
|
||||
"grant".into(),
|
||||
json!({
|
||||
"grant_hash": hex(&self.grant.grant_hash),
|
||||
"capability": self.grant.capability,
|
||||
"joule_ceiling_micro": self.grant.joule_ceiling_micro,
|
||||
"funds_ceiling": self.grant.funds_ceiling,
|
||||
}),
|
||||
);
|
||||
m.insert("parent_receipt_hash".into(), self.parent_receipt_hash.map_or(Value::Null, |h| json!(hex(&h))));
|
||||
m.insert("signer_pubkey".into(), json!(hex(&self.signer_pubkey)));
|
||||
m.insert("signer_id".into(), json!(self.signer_id));
|
||||
m.insert("sig".into(), json!(hex(&self.sig)));
|
||||
Value::Object(m)
|
||||
}
|
||||
|
||||
/// Read the JSON carriage (revenue-share §4.1): exactly its members, each of its type, and
|
||||
/// each entry's end within a u64. A receipt of the legacy class label does not read in it.
|
||||
/// Whether it verifies is [`ChannelReceipt::verify`]'s.
|
||||
pub fn from_json(v: &serde_json::Value) -> Option<ChannelReceipt> {
|
||||
use crate::energy_class::{CounterFiltering, EnergyProvenance as P, Uncertainty};
|
||||
use serde_json::Value;
|
||||
let o = v.as_object()?;
|
||||
let label = match o.get("energy_provenance")? {
|
||||
Value::Null => None,
|
||||
Value::String(s) => Some(s.as_str()),
|
||||
_ => return None,
|
||||
};
|
||||
let mut want: Vec<&str> = CARRIAGE_MEMBERS.to_vec();
|
||||
match label {
|
||||
Some("OnChipCounter") => want.extend(["energy_uncertainty", "energy_counter_filtering"]),
|
||||
Some("CalibratedInstrument") => want.extend(["energy_uncertainty", "energy_calibration_ref"]),
|
||||
_ => {}
|
||||
}
|
||||
if o.len() != want.len() || !want.iter().all(|k| o.contains_key(*k)) {
|
||||
return None;
|
||||
}
|
||||
let uncertainty = || -> Option<Uncertainty> {
|
||||
let u = o.get("energy_uncertainty")?.as_object()?;
|
||||
if u.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
Some(Uncertainty { relative_ppm: u32::try_from(u.get("relative_ppm")?.as_u64()?).ok()?, window_us: u.get("window_us")?.as_u64()? })
|
||||
};
|
||||
let energy_provenance = match label {
|
||||
None => None,
|
||||
Some("ModelBased") => Some(P::ModelBased),
|
||||
Some("Estimator") => Some(P::Estimator),
|
||||
Some("OnChipCounter") => Some(P::OnChipCounter {
|
||||
uncertainty: uncertainty()?,
|
||||
filtering: CounterFiltering::from_label(o.get("energy_counter_filtering")?.as_str()?)?,
|
||||
}),
|
||||
Some("CalibratedInstrument") => Some(P::CalibratedInstrument {
|
||||
uncertainty: uncertainty()?,
|
||||
calibration_ref: o.get("energy_calibration_ref")?.as_str()?.to_string(),
|
||||
}),
|
||||
Some(_) => return None,
|
||||
};
|
||||
let g = o.get("grant")?.as_object()?;
|
||||
if g.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let grant = GrantRef {
|
||||
grant_hash: unhex(g.get("grant_hash")?)?,
|
||||
capability: g.get("capability")?.as_str()?.to_string(),
|
||||
joule_ceiling_micro: g.get("joule_ceiling_micro")?.as_u64()?,
|
||||
funds_ceiling: match g.get("funds_ceiling")? {
|
||||
Value::Null => None,
|
||||
f => Some(f.as_u64()?),
|
||||
},
|
||||
};
|
||||
let mut schedule = Vec::new();
|
||||
for e in o.get("schedule")?.as_array()? {
|
||||
let e = e.as_object()?;
|
||||
if e.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let entry = ScheduleEntry {
|
||||
asset_id: e.get("asset_id")?.as_str()?.to_string(),
|
||||
start_ms: e.get("start_ms")?.as_u64()?,
|
||||
duration_ms: e.get("duration_ms")?.as_u64()?,
|
||||
kind: EntryKind::from_name(e.get("kind")?.as_str()?)?,
|
||||
};
|
||||
entry.start_ms.checked_add(entry.duration_ms)?;
|
||||
schedule.push(entry);
|
||||
}
|
||||
let aired = o.get("aired")?.as_array()?.iter().map(Value::as_u64).collect::<Option<Vec<u64>>>()?;
|
||||
let u = |k: &str| o.get(k)?.as_u64();
|
||||
Some(ChannelReceipt {
|
||||
channel_id: o.get("channel_id")?.as_str()?.to_string(),
|
||||
schedule_root: unhex(o.get("schedule_root")?)?,
|
||||
schedule,
|
||||
window_start_ms: u("window_start_ms")?,
|
||||
window_end_ms: u("window_end_ms")?,
|
||||
aired,
|
||||
work_schedule_ops: u("work_schedule_ops")?,
|
||||
joules_micro: u("joules_micro")?,
|
||||
energy_provenance,
|
||||
grant,
|
||||
parent_receipt_hash: match o.get("parent_receipt_hash")? {
|
||||
Value::Null => None,
|
||||
h => Some(unhex(h)?),
|
||||
},
|
||||
signer_pubkey: unhex(o.get("signer_pubkey")?)?,
|
||||
signer_id: o.get("signer_id")?.as_str()?.to_string(),
|
||||
sig: unhex(o.get("sig")?)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -359,4 +569,41 @@ mod tests {
|
|||
let r = ChannelReceipt::seal(&key(1), "did:key:chan", "fast-42", schedule(), 8_000, 13_000, 6_000, Some(crate::energy_class::test_on_chip()), g, None);
|
||||
crate::sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify());
|
||||
}
|
||||
|
||||
/// The JSON carriage (revenue-share §4.1) reads back the receipt it wrote, for every class,
|
||||
/// so the identity and the verdict a verifier computes from it are the receipt's; the legacy
|
||||
/// label, an extra member and an entry whose end passes a u64 do not read.
|
||||
#[test]
|
||||
fn the_carriage_reads_back_what_it_writes() {
|
||||
use crate::energy_class::{CounterFiltering, EnergyProvenance as P, Uncertainty};
|
||||
let unc = Uncertainty { relative_ppm: 50_000, window_us: 1_000 };
|
||||
let classes = [
|
||||
None,
|
||||
Some(P::ModelBased),
|
||||
Some(P::Estimator),
|
||||
Some(P::OnChipCounter { uncertainty: unc, filtering: CounterFiltering::On }),
|
||||
Some(P::CalibratedInstrument { uncertainty: unc, calibration_ref: "lab-7".into() }),
|
||||
];
|
||||
for (k, class) in classes.into_iter().enumerate() {
|
||||
let j = if class.is_some() { 6_000 } else { 0 };
|
||||
let mut g = GrantRef::unbounded("video.channel");
|
||||
g.funds_ceiling = (k % 2 == 1).then_some(7);
|
||||
let parent = (k % 2 == 0).then_some([9u8; 32]);
|
||||
let r = ChannelReceipt::seal(&key(5), "did:key:chan", "fast-42", schedule(), 8_000, 13_000, j, class, g, parent);
|
||||
assert!(r.verify());
|
||||
let back = ChannelReceipt::from_json(&r.to_json()).expect("the carriage reads");
|
||||
assert_eq!(back, r);
|
||||
assert_eq!(back.receipt_hash(), r.receipt_hash());
|
||||
}
|
||||
let r = ChannelReceipt::seal(&key(5), "o", "c", schedule(), 0, 5_000, 0, None, GrantRef::unbounded("video.channel"), None);
|
||||
let mut extra = r.to_json();
|
||||
extra["form"] = serde_json::json!(1);
|
||||
assert!(ChannelReceipt::from_json(&extra).is_none());
|
||||
let mut legacy = r.to_json();
|
||||
legacy["energy_provenance"] = serde_json::json!("HwShunt");
|
||||
assert!(ChannelReceipt::from_json(&legacy).is_none());
|
||||
let mut past = r.to_json();
|
||||
past["schedule"][0]["start_ms"] = serde_json::json!(u64::MAX);
|
||||
assert!(ChannelReceipt::from_json(&past).is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -412,6 +412,43 @@ fn samples(d: &Path, origin_port: u16) -> Vec<(&'static str, Vec<String>, bool)>
|
|||
more.push(("report.export", s(&["report", "export", &digest, "--target", &url, "--out", "week.wai-fleet.json"]), true));
|
||||
more.push(("report.create", s(&["report", "create", "--target", "http://127.0.0.1:9", "--start", "0", "--end", "1"]), false));
|
||||
}
|
||||
#[cfg(feature = "revenue_share")]
|
||||
{
|
||||
// The revenue share: a contract written from its terms, an owner's countersignature added
|
||||
// to an offered contract, and a statement of the share corpus's October, recomputed here
|
||||
// from its runs and ledger documents.
|
||||
use wai::revenue_share::{corpus, terms};
|
||||
let hexs = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
|
||||
let (operator, owner) = (corpus::key("operator"), corpus::key("owner-a"));
|
||||
std::fs::write(d.join("operator.key"), hexs(&operator.to_bytes())).unwrap();
|
||||
std::fs::write(d.join("owner-a.key"), hexs(&owner.to_bytes())).unwrap();
|
||||
let a = hexs(&owner.verifying_key().to_bytes());
|
||||
let t = serde_json::json!({
|
||||
"start": 0, "end": 10, "currency": "EUR", "channel": "fast-7", "channel_signer": "22".repeat(32), "rule": "preceding", "between": 0,
|
||||
"carriers": [{"room": "11".repeat(32)}], "owners": [a],
|
||||
"splits": [{"operator": 500_000, "owners": {a.clone(): 500_000}}], "assets": {"a/ep-1": 0},
|
||||
});
|
||||
std::fs::write(d.join("terms.json"), t.to_string()).unwrap();
|
||||
std::fs::write(d.join("offered.wrc"), terms(&t).unwrap().offer(&operator).to_bytes()).unwrap();
|
||||
let share: Value = serde_json::from_slice(&std::fs::read(wai_dir().join("live-conformance/share/vectors.json")).unwrap()).unwrap();
|
||||
let ledger = |n: &str| {
|
||||
let l = &share["ledgers"][n];
|
||||
let sources: Vec<Value> = l["sources"].as_array().unwrap().iter().map(|s| share["sources"][s.as_str().unwrap()].clone()).collect();
|
||||
serde_json::json!({"tariff": l["tariff"], "invoice": l["invoice"], "previous": l["previous"], "sources": sources})
|
||||
};
|
||||
let inputs = serde_json::json!({
|
||||
"contract": share["contracts"]["preceding"], "contracts": [],
|
||||
"runs": [share["runs"]["oct_16"], share["runs"]["oct_25"]], "ledgers": [ledger("day_16"), ledger("day_25")],
|
||||
});
|
||||
std::fs::write(d.join("inputs.json"), inputs.to_string()).unwrap();
|
||||
more.push(("contract.create", s(&["contract", "create", "terms.json", "--key", "operator.key", "--out", "c.wrc"]), true));
|
||||
more.push(("contract.update", s(&["contract", "accept", "offered.wrc", "--key", "owner-a.key", "--out", "accepted.wrc"]), true));
|
||||
more.push((
|
||||
"statement.create",
|
||||
s(&["statement", "create", "inputs.json", "--party", &a, "--start", "1790812800000000", "--end", "1793491200000000", "--key", "operator.key", "--out", "a.wai-share.json"]),
|
||||
true,
|
||||
));
|
||||
}
|
||||
#[cfg(feature = "generate")]
|
||||
{
|
||||
let model = wai_dir().join("wai-rs/tests/vectors/generate/generate_spectral_model.json");
|
||||
|
|
|
|||
262
wai/wai-rs/tests/revenue_share.rs
Normal file
262
wai/wai-rs/tests/revenue_share.rs
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
//! `wai.delivery.revenue_share` (extensions/revenue-share.md): the committed corpus
|
||||
//! `live-conformance/share/vectors.json` regenerates byte for byte from its seeds and the ledger
|
||||
//! corpus, and is the only file there besides its README; every vector gets the outcome it states;
|
||||
//! every reason the extension adds and every code it reuses refuses a vector; §11 lists exactly the
|
||||
//! reasons the reference reports; and the worked example of §12: a channel's windows airing two
|
||||
//! owners' programmes with mid-roll breaks, impressions from a room's break-form receipts and from
|
||||
//! streams stitched by others, a contract, the statements, and their recomputation by a party
|
||||
//! holding only the public records.
|
||||
#![cfg(feature = "revenue_share")]
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wai::revenue_share::{self as share, Contract, Reason, Statement, corpus};
|
||||
|
||||
fn wai_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
|
||||
}
|
||||
|
||||
fn read(p: &str) -> String {
|
||||
std::fs::read_to_string(wai_dir().join(p)).unwrap_or_else(|e| panic!("{p}: {e}"))
|
||||
}
|
||||
|
||||
fn committed() -> String {
|
||||
read("live-conformance/share/vectors.json")
|
||||
}
|
||||
|
||||
fn doc() -> Value {
|
||||
serde_json::from_str(&committed()).expect("the corpus is JSON")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_committed_corpus_regenerates_from_its_seeds_and_the_ledger_corpus() {
|
||||
let generated = corpus::generate(&wai_dir()).expect("the corpus generates");
|
||||
assert!(committed() == generated, "live-conformance/share/vectors.json differs from `wai_revenue_share_vectors gen`");
|
||||
let mut files: Vec<String> = std::fs::read_dir(wai_dir().join("live-conformance/share")).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
|
||||
files.sort();
|
||||
assert_eq!(files, ["README.md", "vectors.json"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_vector_gets_its_stated_outcome() {
|
||||
let fails = corpus::verify(&committed());
|
||||
assert!(fails.is_empty(), "{}", fails.join("\n"));
|
||||
}
|
||||
|
||||
/// Every reason this extension adds and every code it reuses refuses a vector, and the receipt
|
||||
/// ledger refuses one at its own place.
|
||||
#[test]
|
||||
fn the_corpus_covers_every_reason_and_case() {
|
||||
let d = doc();
|
||||
let vectors = d["vectors"].as_array().unwrap();
|
||||
let seen: BTreeSet<&str> = vectors.iter().filter_map(|v| v["outcome"]["reason"].as_str()).collect();
|
||||
for r in Reason::ALL {
|
||||
assert!(seen.contains(r.code()), "nothing in the corpus is refused {r}");
|
||||
}
|
||||
for r in Reason::REUSED {
|
||||
assert!(seen.contains(r), "nothing in the corpus is refused {r}");
|
||||
}
|
||||
let get = |n: &str| vectors.iter().find(|v| v["name"] == n).unwrap_or_else(|| panic!("no vector {n}"))["outcome"].clone();
|
||||
assert_eq!(get("ledger_refused")["at"], json!(["ledger", 0, "invoice"]), "the receipt ledger's refusal at its place");
|
||||
assert_eq!(get("other_contract_offered")["at"][0], json!("contracts"));
|
||||
for n in ["owner_a_october", "owner_b_october", "operator_october", "owner_b_following", "owner_a_between", "owner_a_nothing_billed"] {
|
||||
assert_eq!(get(n)["outcome"], "accept", "{n}");
|
||||
}
|
||||
}
|
||||
|
||||
/// §11's table names exactly the codes the reference adds, in its order.
|
||||
#[test]
|
||||
fn section_11_lists_exactly_the_reference_codes() {
|
||||
let text = read("extensions/revenue-share.md");
|
||||
let at = text.find("\n## 11. Reasons").expect("§11");
|
||||
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
|
||||
let listed: Vec<&str> = text[at..end].lines().filter_map(|l| l.strip_prefix("| `")).filter_map(|l| l.split('`').next()).collect();
|
||||
let codes: Vec<&str> = Reason::ALL.iter().map(|r| r.code()).collect();
|
||||
assert_eq!(listed, codes);
|
||||
}
|
||||
|
||||
/// A named part of the corpus.
|
||||
fn part<'a>(d: &'a Value, kind: &str, name: &str) -> &'a Value {
|
||||
d[kind].get(name).unwrap_or_else(|| panic!("no {kind} {name}"))
|
||||
}
|
||||
|
||||
/// The inputs of October under the contract `contract`, from the corpus's named parts.
|
||||
fn october(d: &Value, contract: &str) -> Value {
|
||||
let ledger = |n: &str| {
|
||||
let l = part(d, "ledgers", n);
|
||||
let sources: Vec<Value> = l["sources"].as_array().unwrap().iter().map(|s| part(d, "sources", s.as_str().unwrap()).clone()).collect();
|
||||
json!({"tariff": l["tariff"], "invoice": l["invoice"], "previous": l["previous"], "sources": sources})
|
||||
};
|
||||
json!({
|
||||
"contract": part(d, "contracts", contract),
|
||||
"contracts": [part(d, "contracts", "next_quarter")],
|
||||
"runs": [part(d, "runs", "oct_16"), part(d, "runs", "oct_25")],
|
||||
"ledgers": [ledger("day_16"), ledger("day_25")],
|
||||
})
|
||||
}
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
|
||||
}
|
||||
|
||||
/// The worked example (§12). The operator computes and signs October's statements of owner A,
|
||||
/// owner B and itself from the channel's two windows and two invoices; a party holding only the
|
||||
/// public records receives each verification document as text, verifies it, and recomputes every
|
||||
/// line from the attributed impressions and the contract alone.
|
||||
#[test]
|
||||
fn the_worked_example_recomputes_from_the_public_records() {
|
||||
let d = doc();
|
||||
let inputs = october(&d, "preceding");
|
||||
let (oct_start, oct_end) = (1_790_812_800_000_000u64, 1_793_491_200_000_000u64);
|
||||
let operator = corpus::key("operator");
|
||||
let parties = [("owner-a", corpus::key("owner-a")), ("owner-b", corpus::key("owner-b")), ("operator", operator.clone())];
|
||||
// Each split and class: the revenue each statement states, and the parts of its parties.
|
||||
let mut revenue: BTreeMap<(u16, u8), u64> = BTreeMap::new();
|
||||
let mut parts: BTreeMap<(u16, u8), u64> = BTreeMap::new();
|
||||
for (who, k) in &parties {
|
||||
let (s, document) = share::create(&inputs, &operator, k.verifying_key().to_bytes(), oct_start, oct_end).expect("the statement");
|
||||
// The committed corpus holds this statement, byte for byte.
|
||||
let name = match *who {
|
||||
"owner-a" => "owner_a_october",
|
||||
"owner-b" => "owner_b_october",
|
||||
_ => "operator_october",
|
||||
};
|
||||
let v = d["vectors"].as_array().unwrap().iter().find(|v| v["name"] == name).unwrap();
|
||||
assert_eq!(v["statement"].as_str().map(unhex), Some(s.to_bytes()), "{name}");
|
||||
|
||||
// What the party receives: the document, as text. It holds no key but its own.
|
||||
let text = serde_json::to_string(&document).unwrap();
|
||||
let received: Value = serde_json::from_str(&text).unwrap();
|
||||
let a = share::verify(&received).expect("the statement verifies");
|
||||
let contract = Contract::parse(&unhex(received["contract"].as_str().unwrap())).expect("the contract is in force");
|
||||
let statement = Statement::read(&unhex(received["statement"].as_str().unwrap())).expect("the statement reads");
|
||||
assert_eq!(statement, s);
|
||||
|
||||
// Recompute every line from the attributed impressions and the contract.
|
||||
let mut groups: BTreeMap<(u16, u8), (u64, u64)> = BTreeMap::new();
|
||||
for i in &a.evidence.impressions {
|
||||
let g = groups.entry((i.split, i.class)).or_default();
|
||||
g.0 += 1;
|
||||
g.1 += i.price;
|
||||
}
|
||||
let me = k.verifying_key().to_bytes();
|
||||
let mut total = 0;
|
||||
let mut lines = statement.lines.iter();
|
||||
for ((split, class), (n, rev)) in &groups {
|
||||
let ps = contract.parties(*split);
|
||||
let Some(at) = ps.iter().position(|(p, _)| *p == me) else {
|
||||
continue;
|
||||
};
|
||||
let division = share::divide(*rev, &ps.iter().map(|p| p.1).collect::<Vec<_>>());
|
||||
assert_eq!(division.iter().sum::<u64>(), *rev, "the parts of a split sum to its revenue");
|
||||
let l = lines.next().expect("a line for every split and class the party shares in");
|
||||
assert_eq!((l.split, l.class, l.impressions, l.revenue, l.ppm, l.amount), (*split, *class, *n, *rev, ps[at].1, division[at]));
|
||||
total += l.amount;
|
||||
assert_eq!(*revenue.entry((*split, *class)).or_insert(*rev), *rev, "every statement states one revenue for a split and class");
|
||||
*parts.entry((*split, *class)).or_default() += l.amount;
|
||||
}
|
||||
assert!(lines.next().is_none(), "no line the impressions do not give");
|
||||
assert_eq!(statement.total, total);
|
||||
|
||||
// The join itself: each impression placed on the program-as-run.
|
||||
let assets: Vec<(Option<&str>, u8)> = a.evidence.impressions.iter().map(|i| (i.asset.as_deref(), i.class)).collect();
|
||||
use share::class::{BEACONS, PRESENTED, UNPLACED};
|
||||
let mut want = vec![(Some("a/ep-101"), BEACONS); 3];
|
||||
want.extend(vec![(Some("b/doc-7"), PRESENTED); 4]);
|
||||
// The 25th: two ads of the first record at 12:02:00 and 12:02:15 (the ad slot, by the rule
|
||||
// preceding), the second record's at 12:02:00, the form-1 stream's at 12:20:00, and three
|
||||
// presentations of DASH events, which state no time.
|
||||
want.extend([(Some("a/ep-102"), PRESENTED), (Some("a/ep-102"), PRESENTED), (Some("a/ep-102"), PRESENTED), (Some("b/doc-8"), PRESENTED)]);
|
||||
want.extend(vec![(None, UNPLACED); 3]);
|
||||
assert_eq!(assets, want);
|
||||
}
|
||||
// The three statements' parts of each split and class sum to its revenue.
|
||||
assert_eq!(parts, revenue);
|
||||
assert_eq!(revenue, BTreeMap::from([((0, 1), 3 * 1501), ((0, 2), 3 * 903), ((1, 1), 5 * 1501), ((2, 3), 3 * 1501)]));
|
||||
}
|
||||
|
||||
/// A recomputation is not a re-assertion: every figure of a statement is held to the records.
|
||||
#[test]
|
||||
fn a_statement_that_states_another_figure_is_refused() {
|
||||
let d = doc();
|
||||
let inputs = october(&d, "preceding");
|
||||
let operator = corpus::key("operator");
|
||||
let (s, document) = share::create(&inputs, &operator, corpus::key("owner-a").verifying_key().to_bytes(), 1_790_812_800_000_000, 1_793_491_200_000_000).unwrap();
|
||||
for (what, f, reason) in [
|
||||
("a revenue raised", Box::new(|s: &mut Statement| s.lines[0].revenue += 1) as Box<dyn Fn(&mut Statement)>, "share_statement_mismatch"),
|
||||
("a ppm changed", Box::new(|s: &mut Statement| s.lines[0].ppm += 1), "share_statement_mismatch"),
|
||||
("an invoice dropped", Box::new(|s: &mut Statement| {
|
||||
s.invoices.pop();
|
||||
}), "share_statement_mismatch"),
|
||||
("the total raised", Box::new(|s: &mut Statement| s.total += 1), "share_statement_mismatch"),
|
||||
("a class moved", Box::new(|s: &mut Statement| s.lines[1].class = 3), "line_unsourced"),
|
||||
] {
|
||||
let mut x = s.clone();
|
||||
f(&mut x);
|
||||
let mut doc = document.clone();
|
||||
doc["statement"] = json!(x.sign(&operator).to_bytes().iter().map(|b| format!("{b:02x}")).collect::<String>());
|
||||
assert_eq!(share::verify(&doc).err().map(|e| e.0), Some(reason), "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The four operations from the command line (extensions/revenue-share.md §10): a contract from
|
||||
/// its terms, countersigned by each owner until it is in force; a statement created from the
|
||||
/// corpus's October and verified; and a statement changed after signing, refused with its reason
|
||||
/// as the code at exit 1.
|
||||
#[test]
|
||||
fn the_command_line_creates_countersigns_and_verifies() {
|
||||
use std::process::Command;
|
||||
let d = doc();
|
||||
let dir = std::env::temp_dir().join(format!("wai-share-cli-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let wai = |args: &[&str]| Command::new(env!("CARGO_BIN_EXE_wai")).args(args).arg("--json").current_dir(&dir).output().unwrap();
|
||||
let envelope = |o: &std::process::Output| -> Value { serde_json::from_slice(&o.stdout).unwrap_or_else(|_| panic!("{}", String::from_utf8_lossy(&o.stdout))) };
|
||||
let hexs = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
|
||||
for k in ["operator", "owner-a", "owner-b"] {
|
||||
std::fs::write(dir.join(format!("{k}.key")), hexs(&corpus::key(k).to_bytes())).unwrap();
|
||||
}
|
||||
let pk = |k: &str| hexs(&corpus::key(k).verifying_key().to_bytes());
|
||||
let (a, b) = (pk("owner-a"), pk("owner-b"));
|
||||
let terms = json!({
|
||||
"start": 0, "end": 10, "currency": "EUR", "channel": "fast-7", "channel_signer": "22".repeat(32), "rule": "following", "between": 1,
|
||||
"carriers": [{"room": "11".repeat(32)}, {"stitcher": "33".repeat(32)}], "owners": [b, a],
|
||||
"splits": [{"operator": 400_000, "owners": {a.clone(): 600_000}}, {"operator": 0, "owners": {a.clone(): 500_000, b.clone(): 500_000}}],
|
||||
"assets": {"b/doc-1": 1, "a/ep-1": 0},
|
||||
});
|
||||
std::fs::write(dir.join("terms.json"), terms.to_string()).unwrap();
|
||||
let o = wai(&["contract", "create", "terms.json", "--key", "operator.key", "--out", "c.wrc"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
assert_eq!(envelope(&o)["data"]["in_force"], false);
|
||||
let o = wai(&["contract", "accept", "c.wrc", "--key", "owner-a.key", "--out", "c1.wrc"]);
|
||||
assert_eq!(envelope(&o)["data"]["in_force"], false, "one owner of two");
|
||||
let o = wai(&["contract", "accept", "c1.wrc", "--key", "owner-b.key", "--out", "c2.wrc"]);
|
||||
assert!(o.status.success());
|
||||
assert_eq!(envelope(&o)["data"]["in_force"], true);
|
||||
assert!(Contract::parse(&std::fs::read(dir.join("c2.wrc")).unwrap()).is_ok());
|
||||
// A key the contract does not name countersigns nothing.
|
||||
let o = wai(&["contract", "accept", "c2.wrc", "--key", "operator.key", "--out", "c3.wrc"]);
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
assert!(!dir.join("c3.wrc").exists());
|
||||
|
||||
std::fs::write(dir.join("inputs.json"), october(&d, "preceding").to_string()).unwrap();
|
||||
let o = wai(&["statement", "create", "inputs.json", "--party", &a, "--start", "1790812800000000", "--end", "1793491200000000", "--key", "operator.key", "--out", "a.json"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stdout));
|
||||
let v = envelope(&o);
|
||||
assert_eq!(v["data"]["outcome"]["total"], 1501 + 903 + 2251);
|
||||
let o = wai(&["statement", "verify", "a.json"]);
|
||||
assert!(o.status.success());
|
||||
assert_eq!(envelope(&o)["data"]["outcome"], "accept");
|
||||
// The statement's total changed after signing.
|
||||
let mut doc: Value = serde_json::from_slice(&std::fs::read(dir.join("a.json")).unwrap()).unwrap();
|
||||
let mut s = Statement::read(&unhex(doc["statement"].as_str().unwrap())).unwrap();
|
||||
s.total += 1;
|
||||
doc["statement"] = json!(hexs(&s.sign(&corpus::key("operator")).to_bytes()));
|
||||
std::fs::write(dir.join("b.json"), doc.to_string()).unwrap();
|
||||
let o = wai(&["statement", "verify", "b.json"]);
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
assert_eq!(envelope(&o)["error"]["code"], "share_statement_mismatch");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// GENERATED by scripts/actions.mjs from the WAI operation catalogue. Do not edit: change the
|
||||
// catalogue (wai-rs/src/catalogue.rs, exported by `wai ops export`), then run
|
||||
// `node scripts/actions.mjs`. `node scripts/actions.mjs --check` fails when this file differs.
|
||||
export const CATALOGUE = {"format":"wai.ops/1","toolchain":"0.4.0","sha256":"327f96eaf75e0d852d2db58e9833f0fb6ea85dbafc6dfc1d27ea41614fdcd5b2"};
|
||||
export const CATALOGUE = {"format":"wai.ops/1","toolchain":"0.4.0","sha256":"0ef1aab4f7b44a109dc7a0168c9394b0338458fe5fd23c75feb8892818304e1a"};
|
||||
export const ACTIONS = [
|
||||
{"id":"toolchain.test","noun":"toolchain","verb":"test","menu":"Toolchain","item":"Test","palette":"toolchain test","summary":"WAI's four steps (name, dispatch, rebuild, receipt) on a picture made here, its receipt checked offline","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai hello","mcp":null,"route":null,"call":"toolchain_test"},
|
||||
{"id":"project.create","noun":"project","verb":"create","menu":"Project","item":"Create","palette":"project create","summary":"A new project from one of the examples, written into a new or empty directory; --list names the templates","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai create","mcp":null,"route":null,"call":"project_create"},
|
||||
|
|
@ -95,6 +95,10 @@ export const ACTIONS = [
|
|||
{"id":"inbox.show","noun":"inbox","verb":"show","menu":"Inbox","item":"Show","palette":"inbox show","summary":"The caller's inbox","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/inbox"},"call":"inbox_show"},
|
||||
{"id":"answer.create","noun":"answer","verb":"create","menu":"Answer","item":"Create","palette":"answer create","summary":"Accept, decline or cancel","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/call/answer"},"call":"answer_create"},
|
||||
{"id":"call.show","noun":"call","verb":"show","menu":"Call","item":"Show","palette":"call show","summary":"The current call","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/call"},"call":"call_show"},
|
||||
{"id":"contract.create","noun":"contract","verb":"create","menu":"Contract","item":"Create","palette":"contract create","summary":"A channel's share contract from its terms, signed with the operator key; each owner countersigns it","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai contract create","mcp":null,"route":null,"call":"contract_create"},
|
||||
{"id":"contract.update","noun":"contract","verb":"update","menu":"Contract","item":"Update","palette":"contract update","summary":"An owner's countersignature added to a share contract, after its rules and the operator's signature are checked","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai contract accept","mcp":null,"route":null,"call":"contract_update"},
|
||||
{"id":"statement.create","noun":"statement","verb":"create","menu":"Statement","item":"Create","palette":"statement create","summary":"A party's revenue-share statement for a period, recomputed from the ledger's invoices and the channel's program-as-run, signed with the operator key","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai statement create","mcp":null,"route":null,"call":"statement_create"},
|
||||
{"id":"statement.verify","noun":"statement","verb":"verify","menu":"Statement","item":"Verify","palette":"statement verify","summary":"A revenue-share statement's verification document checked: every line recomputed from the public records, or the reason it is refused","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai statement verify","mcp":null,"route":null,"call":"statement_verify"},
|
||||
{"id":"ops.list","noun":"ops","verb":"list","menu":"Ops","item":"List","palette":"ops list","summary":"Every operation of this catalogue: its id, command line, effect and whether this build holds it","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai ops list","mcp":null,"route":null,"call":"ops_list"},
|
||||
{"id":"ops.search","noun":"ops","verb":"search","menu":"Ops","item":"Search","palette":"ops search","summary":"The operations that match a task, ranked, offline: up to five","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai ops search","mcp":null,"route":null,"call":"ops_search"},
|
||||
{"id":"ops.show","noun":"ops","verb":"show","menu":"Ops","item":"Show","palette":"ops show","summary":"One operation's entry: its input and output schemas, effect, placement and command line","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai ops show","mcp":null,"route":null,"call":"ops_show"},
|
||||
|
|
|
|||
Loading…
Reference in a new issue