@obbyx/obyte 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +15 -2
- package/dist/channel/definition.d.ts +38 -0
- package/dist/channel/definition.d.ts.map +1 -0
- package/dist/channel/definition.js +86 -0
- package/dist/channel/definition.js.map +1 -0
- package/dist/config/index.d.ts +31 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +33 -0
- package/dist/config/index.js.map +1 -0
- package/dist/core/amounts.d.ts +35 -0
- package/dist/core/amounts.d.ts.map +1 -0
- package/dist/core/amounts.js +94 -0
- package/dist/core/amounts.js.map +1 -0
- package/dist/core/errors.d.ts +16 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +18 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/networks.d.ts +34 -0
- package/dist/core/networks.d.ts.map +1 -0
- package/dist/core/networks.js +50 -0
- package/dist/core/networks.js.map +1 -0
- package/dist/core/result.d.ts +17 -0
- package/dist/core/result.d.ts.map +1 -0
- package/dist/core/result.js +13 -0
- package/dist/core/result.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/logger/index.d.ts +76 -0
- package/dist/logger/index.d.ts.map +1 -0
- package/dist/logger/index.js +161 -0
- package/dist/logger/index.js.map +1 -0
- package/dist/signing/signed-message.d.ts +47 -0
- package/dist/signing/signed-message.d.ts.map +1 -0
- package/dist/signing/signed-message.js +102 -0
- package/dist/signing/signed-message.js.map +1 -0
- package/dist/unit/exact-unit.d.ts +94 -0
- package/dist/unit/exact-unit.d.ts.map +1 -0
- package/dist/unit/exact-unit.js +387 -0
- package/dist/unit/exact-unit.js.map +1 -0
- package/dist/wallet/index.d.ts +29 -0
- package/dist/wallet/index.d.ts.map +1 -0
- package/dist/wallet/index.js +106 -0
- package/dist/wallet/index.js.map +1 -0
- package/package.json +64 -3
- package/src/channel/definition.ts +114 -0
- package/src/config/index.ts +40 -0
- package/src/core/amounts.ts +116 -0
- package/src/core/errors.ts +22 -0
- package/src/core/networks.ts +81 -0
- package/src/core/result.ts +23 -0
- package/src/index.ts +58 -0
- package/src/logger/index.ts +234 -0
- package/src/signing/signed-message.ts +147 -0
- package/src/types/obyte-internal.d.ts +19 -0
- package/src/unit/exact-unit.ts +447 -0
- package/src/wallet/index.ts +130 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Ambient types for obyte.js internals; a reference (not tsconfig "include") so that
|
|
2
|
+
// workspace packages compiling these sources via the @obbyx/source condition see them.
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
|
4
|
+
/// <reference path="../types/obyte-internal.d.ts" />
|
|
5
|
+
import obyte from 'obyte';
|
|
6
|
+
import { getSignedPackageHashToSign, verify } from 'obyte/lib/internal.js';
|
|
7
|
+
import { isAddress } from '../channel/definition.js';
|
|
8
|
+
import type { ObyteNetwork } from '../core/networks.js';
|
|
9
|
+
import { type Verdict, accept, hasOnlyKeys, isPlainObject, reject } from '../core/result.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Obyte signed messages ("signed packages") as used by AA payment channels.
|
|
13
|
+
*
|
|
14
|
+
* Supported profile (same as aa-channels-lib's light verifier, plus network
|
|
15
|
+
* binding): exactly one author, an embedded `['sig', {pubkey}]` definition
|
|
16
|
+
* whose chash equals the author address, signature under path `r`, and a
|
|
17
|
+
* `version` that belongs to the expected network (testnet versions end in
|
|
18
|
+
* `t`) — so a package can never be accepted on the other network (I4).
|
|
19
|
+
*
|
|
20
|
+
* Packages are signed with obyte.js directly:
|
|
21
|
+
* `obyte.utils.signMessage(message, { privateKey, testnet })`.
|
|
22
|
+
*/
|
|
23
|
+
export type SigDefinition = readonly ['sig', { readonly pubkey: string }];
|
|
24
|
+
|
|
25
|
+
export interface SignedPackage<M = unknown> {
|
|
26
|
+
version: string;
|
|
27
|
+
signed_message: M;
|
|
28
|
+
authors: [
|
|
29
|
+
{
|
|
30
|
+
address: string;
|
|
31
|
+
definition: SigDefinition;
|
|
32
|
+
authentifiers: { r: string };
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface VerifiedPackage<M = unknown> {
|
|
38
|
+
readonly signer: string;
|
|
39
|
+
readonly pubkey: string;
|
|
40
|
+
readonly message: M;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const SIGNED_PACKAGE_VERSIONS = {
|
|
44
|
+
livenet: ['1.0', '2.0', '3.0', '4.0'],
|
|
45
|
+
testnet: ['1.0t', '2.0t', '3.0t', '4.0t'],
|
|
46
|
+
} as const;
|
|
47
|
+
|
|
48
|
+
const PACKAGE_KEYS = new Set([
|
|
49
|
+
'signed_message',
|
|
50
|
+
'authors',
|
|
51
|
+
'last_ball_unit',
|
|
52
|
+
'timestamp',
|
|
53
|
+
'version',
|
|
54
|
+
]);
|
|
55
|
+
const AUTHOR_KEYS = new Set(['address', 'definition', 'authentifiers']);
|
|
56
|
+
|
|
57
|
+
export function verifySignedPackage<M = unknown>(
|
|
58
|
+
pkg: unknown,
|
|
59
|
+
options: { network: ObyteNetwork; expectedSigner?: string },
|
|
60
|
+
): Verdict<VerifiedPackage<M>> {
|
|
61
|
+
if (!isPlainObject(pkg)) return reject('invalid_signed_package', 'Package must be an object');
|
|
62
|
+
if (!hasOnlyKeys(pkg, PACKAGE_KEYS)) {
|
|
63
|
+
return reject('invalid_signed_package', 'Package has unexpected fields');
|
|
64
|
+
}
|
|
65
|
+
if (!('signed_message' in pkg)) {
|
|
66
|
+
return reject('invalid_signed_package', 'Package has no signed_message');
|
|
67
|
+
}
|
|
68
|
+
const versions: readonly string[] = SIGNED_PACKAGE_VERSIONS[options.network.name];
|
|
69
|
+
if (typeof pkg['version'] !== 'string' || !versions.includes(pkg['version'])) {
|
|
70
|
+
return reject(
|
|
71
|
+
'invalid_signed_package_network',
|
|
72
|
+
`Package version is not valid for ${options.network.caip2}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const authors = pkg['authors'];
|
|
77
|
+
if (!Array.isArray(authors) || authors.length !== 1) {
|
|
78
|
+
return reject('invalid_signed_package', 'Package must have exactly one author');
|
|
79
|
+
}
|
|
80
|
+
const author: unknown = authors[0];
|
|
81
|
+
if (!isPlainObject(author) || !hasOnlyKeys(author, AUTHOR_KEYS)) {
|
|
82
|
+
return reject('invalid_signed_package', 'Malformed author');
|
|
83
|
+
}
|
|
84
|
+
const address = author['address'];
|
|
85
|
+
if (!isAddress(address)) return reject('invalid_signed_package', 'Invalid author address');
|
|
86
|
+
if (options.expectedSigner !== undefined && address !== options.expectedSigner) {
|
|
87
|
+
return reject('invalid_signed_package_signer', 'Package is signed by an unexpected address');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const definition = author['definition'];
|
|
91
|
+
if (!isSigDefinition(definition)) {
|
|
92
|
+
return reject(
|
|
93
|
+
'invalid_signed_package_definition',
|
|
94
|
+
"Only ['sig', {pubkey}] definitions are supported",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
let chash: string;
|
|
98
|
+
try {
|
|
99
|
+
chash = obyte.utils.getChash160([...definition]);
|
|
100
|
+
} catch {
|
|
101
|
+
return reject('invalid_signed_package_definition', 'Definition cannot be hashed');
|
|
102
|
+
}
|
|
103
|
+
if (chash !== address) {
|
|
104
|
+
return reject('invalid_signed_package_definition', 'Definition does not match author address');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const authentifiers = author['authentifiers'];
|
|
108
|
+
if (!isPlainObject(authentifiers) || Object.keys(authentifiers).length !== 1) {
|
|
109
|
+
return reject('invalid_signed_package_signature', 'Expected a single authentifier "r"');
|
|
110
|
+
}
|
|
111
|
+
let hash: Uint8Array;
|
|
112
|
+
try {
|
|
113
|
+
hash = getSignedPackageHashToSign(pkg);
|
|
114
|
+
} catch {
|
|
115
|
+
return reject('invalid_signed_package', 'Package cannot be hashed');
|
|
116
|
+
}
|
|
117
|
+
if (!verifySignature(hash, authentifiers['r'], definition[1].pubkey)) {
|
|
118
|
+
return reject('invalid_signed_package_signature', 'Signature is invalid');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return accept({
|
|
122
|
+
signer: address,
|
|
123
|
+
pubkey: definition[1].pubkey,
|
|
124
|
+
message: pkg['signed_message'] as M,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function isSigDefinition(value: unknown): value is SigDefinition {
|
|
129
|
+
if (!Array.isArray(value) || value.length !== 2 || value[0] !== 'sig') return false;
|
|
130
|
+
const params: unknown = value[1];
|
|
131
|
+
return (
|
|
132
|
+
isPlainObject(params) &&
|
|
133
|
+
Object.keys(params).length === 1 &&
|
|
134
|
+
typeof params['pubkey'] === 'string' &&
|
|
135
|
+
params['pubkey'].length === 44
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** secp256k1 verification that never throws on malformed input. */
|
|
140
|
+
export function verifySignature(hash: Uint8Array, signature: unknown, pubkey: string): boolean {
|
|
141
|
+
if (typeof signature !== 'string') return false;
|
|
142
|
+
try {
|
|
143
|
+
return verify(hash, signature, pubkey);
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for obyte.js internals used directly (ADR-0006). obyte.js publishes
|
|
3
|
+
* types only for its public API; these helpers live in `lib/internal.js`.
|
|
4
|
+
* The obyte.js version is pinned exactly; conformance tests against testnet
|
|
5
|
+
* ledger data catch behavioural drift on upgrade.
|
|
6
|
+
*/
|
|
7
|
+
declare module 'obyte/lib/internal.js' {
|
|
8
|
+
export function getUnitHash(unit: object): string;
|
|
9
|
+
export function getUnitHashToSign(unit: object): Uint8Array;
|
|
10
|
+
export function getSignedPackageHashToSign(pkg: object): Uint8Array;
|
|
11
|
+
export function getBase64Hash(obj: unknown, jsonBased: boolean): string;
|
|
12
|
+
export function getHeadersSize(unit: object, withKeys: boolean): number;
|
|
13
|
+
export function getTotalPayloadSize(unit: object, withKeys: boolean): number;
|
|
14
|
+
export function sign(hash: Uint8Array, privateKey: Uint8Array): string;
|
|
15
|
+
export function verify(hash: Uint8Array, signature: string, pubkey: string): boolean;
|
|
16
|
+
export function toPublicKey(privateKey: Uint8Array): string;
|
|
17
|
+
export function getSourceString(obj: unknown): string;
|
|
18
|
+
export function chashGetChash160(sourceString: string): string;
|
|
19
|
+
}
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// Ambient types for obyte.js internals; a reference (not tsconfig "include") so that
|
|
2
|
+
// workspace packages compiling these sources via the @obbyx/source condition see them.
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
|
4
|
+
/// <reference path="../types/obyte-internal.d.ts" />
|
|
5
|
+
import obyte from 'obyte';
|
|
6
|
+
import {
|
|
7
|
+
getBase64Hash,
|
|
8
|
+
getHeadersSize,
|
|
9
|
+
getTotalPayloadSize,
|
|
10
|
+
getUnitHash,
|
|
11
|
+
getUnitHashToSign,
|
|
12
|
+
} from 'obyte/lib/internal.js';
|
|
13
|
+
import { isAddress, isAssetId } from '../channel/definition.js';
|
|
14
|
+
import type { ObyteNetwork } from '../core/networks.js';
|
|
15
|
+
import { type Verdict, accept, hasOnlyKeys, isPlainObject, reject } from '../core/result.js';
|
|
16
|
+
import { type SigDefinition, isSigDefinition, verifySignature } from '../signing/signed-message.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Local (ledger-independent) validation of a client-signed unit for the x402
|
|
20
|
+
* `exact` scheme — the supported profile of ADR-0002.
|
|
21
|
+
*
|
|
22
|
+
* Checks done here need no hub: structure, network, unit hash, payload hashes,
|
|
23
|
+
* author definition and signature, commissions, message set, outputs and the
|
|
24
|
+
* signed binding. Ledger checks (inputs unspent, last ball recency, author
|
|
25
|
+
* definition when not embedded) are performed by the facilitator with a
|
|
26
|
+
* LedgerPort using {@link ExactUnitFacts}.
|
|
27
|
+
*
|
|
28
|
+
* Anything outside the profile is rejected; the profile is deliberately narrow.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export const EXACT_REASONS = {
|
|
32
|
+
unsupported: 'invalid_exact_obyte_unsupported_unit',
|
|
33
|
+
tooLarge: 'invalid_exact_obyte_unit_too_large',
|
|
34
|
+
network: 'invalid_exact_obyte_network',
|
|
35
|
+
unitHash: 'invalid_exact_obyte_unit_hash',
|
|
36
|
+
payloadHash: 'invalid_exact_obyte_payload_hash',
|
|
37
|
+
definition: 'invalid_exact_obyte_definition',
|
|
38
|
+
definitionRequired: 'invalid_exact_obyte_definition_required',
|
|
39
|
+
signature: 'invalid_exact_obyte_signature',
|
|
40
|
+
commissions: 'invalid_exact_obyte_commissions',
|
|
41
|
+
recipient: 'invalid_exact_obyte_recipient_mismatch',
|
|
42
|
+
amount: 'invalid_exact_obyte_amount_mismatch',
|
|
43
|
+
asset: 'invalid_exact_obyte_asset_mismatch',
|
|
44
|
+
binding: 'invalid_exact_obyte_binding_mismatch',
|
|
45
|
+
payer: 'invalid_exact_obyte_payer',
|
|
46
|
+
} as const;
|
|
47
|
+
|
|
48
|
+
/** Only current unit versions are accepted. */
|
|
49
|
+
export const EXACT_UNIT_VERSIONS = { livenet: '4.0', testnet: '4.0t' } as const;
|
|
50
|
+
const ALT = { livenet: '1', testnet: '2' } as const;
|
|
51
|
+
|
|
52
|
+
export const DEFAULT_MAX_UNIT_BYTES = 16 * 1024;
|
|
53
|
+
|
|
54
|
+
export interface ExactExpectation {
|
|
55
|
+
readonly network: ObyteNetwork;
|
|
56
|
+
readonly payTo: string;
|
|
57
|
+
/** `'base'` or asset id; must be allowlisted by the caller beforehand. */
|
|
58
|
+
readonly asset: string;
|
|
59
|
+
readonly amount: bigint;
|
|
60
|
+
readonly binding: string;
|
|
61
|
+
readonly quoteId: string;
|
|
62
|
+
/** Facilitator's own addresses: they must never be the payer. */
|
|
63
|
+
readonly facilitatorAddresses?: readonly string[];
|
|
64
|
+
readonly maxUnitBytes?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface InputRef {
|
|
68
|
+
readonly asset: string;
|
|
69
|
+
readonly unit: string;
|
|
70
|
+
readonly messageIndex: number;
|
|
71
|
+
readonly outputIndex: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ExactUnitFacts {
|
|
75
|
+
readonly unitHash: string;
|
|
76
|
+
readonly payer: string;
|
|
77
|
+
readonly inputs: readonly InputRef[];
|
|
78
|
+
readonly lastBallUnit: string;
|
|
79
|
+
readonly parentUnits: readonly string[];
|
|
80
|
+
readonly timestamp: number;
|
|
81
|
+
/** Sum of outputs per asset ('base' for bytes), used to check inputs balance. */
|
|
82
|
+
readonly outputTotals: Readonly<Record<string, bigint>>;
|
|
83
|
+
readonly commissions: { readonly headers: number; readonly payload: number };
|
|
84
|
+
/** True when the author definition was not embedded and came from the caller. */
|
|
85
|
+
readonly definitionFromLedger: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const UNIT_KEYS = new Set([
|
|
89
|
+
'version',
|
|
90
|
+
'alt',
|
|
91
|
+
'messages',
|
|
92
|
+
'authors',
|
|
93
|
+
'parent_units',
|
|
94
|
+
'last_ball',
|
|
95
|
+
'last_ball_unit',
|
|
96
|
+
'timestamp',
|
|
97
|
+
'headers_commission',
|
|
98
|
+
'payload_commission',
|
|
99
|
+
'tps_fee',
|
|
100
|
+
'burn_fee',
|
|
101
|
+
'max_aa_responses',
|
|
102
|
+
'unit',
|
|
103
|
+
]);
|
|
104
|
+
const AUTHOR_KEYS = new Set(['address', 'authentifiers', 'definition']);
|
|
105
|
+
const MESSAGE_KEYS = new Set(['app', 'payload_location', 'payload_hash', 'payload']);
|
|
106
|
+
const PAYMENT_KEYS = new Set(['asset', 'inputs', 'outputs']);
|
|
107
|
+
const INPUT_KEYS = new Set(['unit', 'message_index', 'output_index']);
|
|
108
|
+
const OUTPUT_KEYS = new Set(['address', 'amount']);
|
|
109
|
+
const HASH_RE = /^[A-Za-z0-9+/]{43}=$/;
|
|
110
|
+
|
|
111
|
+
const isHash = (v: unknown): v is string => typeof v === 'string' && HASH_RE.test(v);
|
|
112
|
+
const isIndex = (v: unknown): v is number => Number.isSafeInteger(v) && (v as number) >= 0;
|
|
113
|
+
const isPositiveAmount = (v: unknown): v is number => Number.isSafeInteger(v) && (v as number) > 0;
|
|
114
|
+
|
|
115
|
+
export function validateExactUnit(
|
|
116
|
+
unit: unknown,
|
|
117
|
+
expect: ExactExpectation,
|
|
118
|
+
options: { authorDefinition?: SigDefinition } = {},
|
|
119
|
+
): Verdict<ExactUnitFacts> {
|
|
120
|
+
// The order of the checks is part of the contract: the first failing one
|
|
121
|
+
// decides the reason code.
|
|
122
|
+
const shaped = checkShape(unit, expect);
|
|
123
|
+
if (!shaped.ok) return shaped;
|
|
124
|
+
const u = shaped.value;
|
|
125
|
+
const hashed = checkUnitHash(u);
|
|
126
|
+
if (!hashed.ok) return hashed;
|
|
127
|
+
const { unitHash, messages, authors } = hashed.value;
|
|
128
|
+
const author = checkAuthor(u, authors, options.authorDefinition);
|
|
129
|
+
if (!author.ok) return author;
|
|
130
|
+
const { payer, definitionFromLedger } = author.value;
|
|
131
|
+
const roles = checkPayer(payer, expect);
|
|
132
|
+
if (!roles.ok) return roles;
|
|
133
|
+
const payments = checkMessages(messages, expect);
|
|
134
|
+
if (!payments.ok) return payments;
|
|
135
|
+
const transfers = checkTransfers(payments.value, payer, expect);
|
|
136
|
+
if (!transfers.ok) return transfers;
|
|
137
|
+
const commissions = expectedCommissions(u);
|
|
138
|
+
if (
|
|
139
|
+
commissions.headers !== u['headers_commission'] ||
|
|
140
|
+
commissions.payload !== u['payload_commission']
|
|
141
|
+
) {
|
|
142
|
+
return reject(EXACT_REASONS.commissions, 'Commissions do not match the unit size');
|
|
143
|
+
}
|
|
144
|
+
return accept({
|
|
145
|
+
unitHash,
|
|
146
|
+
payer,
|
|
147
|
+
inputs: transfers.value.inputs,
|
|
148
|
+
lastBallUnit: u['last_ball_unit'] as string,
|
|
149
|
+
parentUnits: u['parent_units'] as string[],
|
|
150
|
+
timestamp: u['timestamp'] as number,
|
|
151
|
+
outputTotals: transfers.value.outputTotals,
|
|
152
|
+
commissions,
|
|
153
|
+
definitionFromLedger,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const unsupported = (why: string) => reject<never>(EXACT_REASONS.unsupported, why);
|
|
158
|
+
|
|
159
|
+
/** Structure, size, network and header fields. */
|
|
160
|
+
function checkShape(unit: unknown, expect: ExactExpectation): Verdict<Record<string, unknown>> {
|
|
161
|
+
if (!isPlainObject(unit)) return unsupported('Unit must be an object');
|
|
162
|
+
let serialized: string;
|
|
163
|
+
try {
|
|
164
|
+
serialized = JSON.stringify(unit);
|
|
165
|
+
} catch {
|
|
166
|
+
return unsupported('Unit is not serializable');
|
|
167
|
+
}
|
|
168
|
+
if (serialized.length > (expect.maxUnitBytes ?? DEFAULT_MAX_UNIT_BYTES)) {
|
|
169
|
+
return reject(EXACT_REASONS.tooLarge, 'Unit exceeds the size limit');
|
|
170
|
+
}
|
|
171
|
+
if (!hasOnlyKeys(unit, UNIT_KEYS)) return unsupported('Unit has fields outside the profile');
|
|
172
|
+
|
|
173
|
+
const net = expect.network.name;
|
|
174
|
+
if (unit['version'] !== EXACT_UNIT_VERSIONS[net] || unit['alt'] !== ALT[net]) {
|
|
175
|
+
return reject(EXACT_REASONS.network, `Unit version/alt do not match ${expect.network.caip2}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const parents = unit['parent_units'];
|
|
179
|
+
if (!Array.isArray(parents) || parents.length === 0 || !parents.every(isHash)) {
|
|
180
|
+
return unsupported('Invalid parent_units');
|
|
181
|
+
}
|
|
182
|
+
if (!isHash(unit['last_ball']) || !isHash(unit['last_ball_unit'])) {
|
|
183
|
+
return unsupported('Invalid last ball');
|
|
184
|
+
}
|
|
185
|
+
if (!Number.isSafeInteger(unit['timestamp'])) return unsupported('Invalid timestamp');
|
|
186
|
+
for (const fee of ['headers_commission', 'payload_commission'] as const) {
|
|
187
|
+
if (!Number.isSafeInteger(unit[fee]) || (unit[fee] as number) <= 0) {
|
|
188
|
+
return unsupported(`Invalid ${fee}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (const fee of ['tps_fee', 'burn_fee', 'max_aa_responses'] as const) {
|
|
192
|
+
if (fee in unit && !isIndex(unit[fee])) return unsupported(`Invalid ${fee}`);
|
|
193
|
+
}
|
|
194
|
+
return accept(unit);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The unit hash is present and matches the content. */
|
|
198
|
+
function checkUnitHash(
|
|
199
|
+
unit: Record<string, unknown>,
|
|
200
|
+
): Verdict<{ unitHash: string; messages: unknown[]; authors: unknown[] }> {
|
|
201
|
+
if (!isHash(unit['unit'])) return reject(EXACT_REASONS.unitHash, 'Missing unit hash');
|
|
202
|
+
const messages = unit['messages'];
|
|
203
|
+
const authors = unit['authors'];
|
|
204
|
+
if (!Array.isArray(messages) || !Array.isArray(authors)) return unsupported('Malformed unit');
|
|
205
|
+
let unitHash: string;
|
|
206
|
+
try {
|
|
207
|
+
unitHash = getUnitHash(unit);
|
|
208
|
+
} catch {
|
|
209
|
+
return reject(EXACT_REASONS.unitHash, 'Unit cannot be hashed');
|
|
210
|
+
}
|
|
211
|
+
if (unitHash !== unit['unit']) {
|
|
212
|
+
return reject(EXACT_REASONS.unitHash, 'Unit hash does not match content');
|
|
213
|
+
}
|
|
214
|
+
return accept({ unitHash, messages, authors });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** One author with a single-sig definition (embedded or from the ledger) and a valid signature. */
|
|
218
|
+
function checkAuthor(
|
|
219
|
+
unit: Record<string, unknown>,
|
|
220
|
+
authors: unknown[],
|
|
221
|
+
ledgerDefinition: SigDefinition | undefined,
|
|
222
|
+
): Verdict<{ payer: string; definitionFromLedger: boolean }> {
|
|
223
|
+
const R = EXACT_REASONS;
|
|
224
|
+
if (authors.length !== 1) return unsupported('Exactly one author is supported');
|
|
225
|
+
const author: unknown = authors[0];
|
|
226
|
+
if (!isPlainObject(author) || !hasOnlyKeys(author, AUTHOR_KEYS)) {
|
|
227
|
+
return unsupported('Malformed author');
|
|
228
|
+
}
|
|
229
|
+
const payer = author['address'];
|
|
230
|
+
if (!isAddress(payer)) return unsupported('Invalid author address');
|
|
231
|
+
|
|
232
|
+
const embedded = 'definition' in author;
|
|
233
|
+
const definition: unknown = embedded ? author['definition'] : ledgerDefinition;
|
|
234
|
+
if (definition === undefined) {
|
|
235
|
+
return reject(
|
|
236
|
+
R.definitionRequired,
|
|
237
|
+
'Author definition is not embedded; fetch it from the ledger',
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (!isSigDefinition(definition)) {
|
|
241
|
+
return reject(R.definition, "Only ['sig', {pubkey}] author definitions are supported");
|
|
242
|
+
}
|
|
243
|
+
if (obyte.utils.getChash160([...definition]) !== payer) {
|
|
244
|
+
return reject(R.definition, 'Definition does not match the author address');
|
|
245
|
+
}
|
|
246
|
+
const authentifiers = author['authentifiers'];
|
|
247
|
+
if (!isPlainObject(authentifiers) || !hasOnlyKeys(authentifiers, new Set(['r']))) {
|
|
248
|
+
return reject(R.signature, 'Expected a single authentifier "r"');
|
|
249
|
+
}
|
|
250
|
+
if (!verifySignature(getUnitHashToSign(unit), authentifiers['r'], definition[1].pubkey)) {
|
|
251
|
+
return reject(R.signature, 'Signature is invalid');
|
|
252
|
+
}
|
|
253
|
+
return accept({ payer, definitionFromLedger: !embedded });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The payer is neither the recipient nor the facilitator. */
|
|
257
|
+
function checkPayer(payer: string, expect: ExactExpectation): Verdict<null> {
|
|
258
|
+
if (payer === expect.payTo) return reject(EXACT_REASONS.payer, 'Payer and payTo must differ');
|
|
259
|
+
if (expect.facilitatorAddresses?.includes(payer)) {
|
|
260
|
+
return reject(EXACT_REASONS.payer, 'A facilitator address must never be the payer');
|
|
261
|
+
}
|
|
262
|
+
return accept(null);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Inline messages with matching hashes: exactly one data message carrying the
|
|
267
|
+
* binding, one payment per asset, bytes always and only the expected asset besides.
|
|
268
|
+
*/
|
|
269
|
+
function checkMessages(
|
|
270
|
+
messages: unknown[],
|
|
271
|
+
expect: ExactExpectation,
|
|
272
|
+
): Verdict<Map<string, Record<string, unknown>>> {
|
|
273
|
+
const R = EXACT_REASONS;
|
|
274
|
+
let dataCount = 0;
|
|
275
|
+
const payments = new Map<string, Record<string, unknown>>();
|
|
276
|
+
for (const message of messages) {
|
|
277
|
+
if (!isPlainObject(message) || !hasOnlyKeys(message, MESSAGE_KEYS)) {
|
|
278
|
+
return unsupported('Malformed message');
|
|
279
|
+
}
|
|
280
|
+
if (message['payload_location'] !== 'inline' || !('payload' in message)) {
|
|
281
|
+
return unsupported('Only inline payloads are supported');
|
|
282
|
+
}
|
|
283
|
+
if (getBase64Hash(message['payload'], true) !== message['payload_hash']) {
|
|
284
|
+
return reject(R.payloadHash, 'payload_hash does not match payload');
|
|
285
|
+
}
|
|
286
|
+
const payload = message['payload'];
|
|
287
|
+
if (message['app'] === 'data') {
|
|
288
|
+
dataCount += 1;
|
|
289
|
+
const binding = checkBinding(payload, expect);
|
|
290
|
+
if (binding !== undefined) return binding;
|
|
291
|
+
} else if (message['app'] === 'payment') {
|
|
292
|
+
const asset = paymentAsset(payload);
|
|
293
|
+
if (!asset.ok) return asset;
|
|
294
|
+
if (payments.has(asset.value)) return unsupported('One payment message per asset');
|
|
295
|
+
payments.set(asset.value, payload as Record<string, unknown>);
|
|
296
|
+
} else {
|
|
297
|
+
return unsupported('Only payment and data messages are supported');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (dataCount !== 1) {
|
|
301
|
+
return reject(R.binding, 'Exactly one data message with the binding is required');
|
|
302
|
+
}
|
|
303
|
+
if (!payments.has('base')) return unsupported('A bytes payment message is required');
|
|
304
|
+
for (const asset of payments.keys()) {
|
|
305
|
+
if (asset !== 'base' && asset !== expect.asset) {
|
|
306
|
+
return reject(R.asset, 'Payment in an unexpected asset');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (!payments.has(expect.asset)) return reject(R.asset, 'No payment in the required asset');
|
|
310
|
+
return accept(payments);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The asset of a payment message ('base' when omitted). */
|
|
314
|
+
function paymentAsset(payload: unknown): Verdict<string> {
|
|
315
|
+
if (!isPlainObject(payload) || !hasOnlyKeys(payload, PAYMENT_KEYS)) {
|
|
316
|
+
return unsupported('Malformed payment');
|
|
317
|
+
}
|
|
318
|
+
const asset = 'asset' in payload ? payload['asset'] : 'base';
|
|
319
|
+
if (asset === 'base' && 'asset' in payload) return unsupported('Bytes payment must omit asset');
|
|
320
|
+
if (!isAssetId(asset)) return unsupported('Invalid payment asset');
|
|
321
|
+
return accept(asset);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Transfer inputs only (no duplicates); sorted outputs that go to payTo
|
|
326
|
+
* (exactly once, the exact amount, the expected asset) or back to the payer.
|
|
327
|
+
*/
|
|
328
|
+
function checkTransfers(
|
|
329
|
+
payments: Map<string, Record<string, unknown>>,
|
|
330
|
+
payer: string,
|
|
331
|
+
expect: ExactExpectation,
|
|
332
|
+
): Verdict<{ inputs: InputRef[]; outputTotals: Record<string, bigint> }> {
|
|
333
|
+
const R = EXACT_REASONS;
|
|
334
|
+
const inputs: InputRef[] = [];
|
|
335
|
+
const seenInputs = new Set<string>();
|
|
336
|
+
const outputTotals: Record<string, bigint> = {};
|
|
337
|
+
let paidToPayTo = 0n;
|
|
338
|
+
let outputsToPayTo = 0;
|
|
339
|
+
for (const [asset, payment] of payments) {
|
|
340
|
+
const ins = payment['inputs'];
|
|
341
|
+
const outs = payment['outputs'];
|
|
342
|
+
if (!Array.isArray(ins) || ins.length === 0 || !Array.isArray(outs) || outs.length === 0) {
|
|
343
|
+
return unsupported('Payment needs inputs and outputs');
|
|
344
|
+
}
|
|
345
|
+
for (const input of ins) {
|
|
346
|
+
const ref = transferInput(input, asset);
|
|
347
|
+
if (ref === undefined) return unsupported('Only transfer inputs are supported');
|
|
348
|
+
const key = `${ref.unit}:${ref.messageIndex}:${ref.outputIndex}`;
|
|
349
|
+
if (seenInputs.has(key)) return unsupported('Duplicate input');
|
|
350
|
+
seenInputs.add(key);
|
|
351
|
+
inputs.push(ref);
|
|
352
|
+
}
|
|
353
|
+
let total = 0n;
|
|
354
|
+
let previous: { address: string; amount: number } | undefined;
|
|
355
|
+
for (const output of outs) {
|
|
356
|
+
if (
|
|
357
|
+
!isPlainObject(output) ||
|
|
358
|
+
!hasOnlyKeys(output, OUTPUT_KEYS) ||
|
|
359
|
+
!isAddress(output['address']) ||
|
|
360
|
+
!isPositiveAmount(output['amount'])
|
|
361
|
+
) {
|
|
362
|
+
return unsupported('Malformed output');
|
|
363
|
+
}
|
|
364
|
+
// ocore requires outputs sorted by address, then amount; duplicates are
|
|
365
|
+
// outside our profile.
|
|
366
|
+
const current = { address: output['address'], amount: output['amount'] };
|
|
367
|
+
if (previous !== undefined && compareOutputs(previous, current) >= 0) {
|
|
368
|
+
return unsupported('Outputs must be sorted by address and amount without duplicates');
|
|
369
|
+
}
|
|
370
|
+
previous = current;
|
|
371
|
+
total += BigInt(current.amount);
|
|
372
|
+
if (current.address === expect.payTo) {
|
|
373
|
+
if (asset !== expect.asset)
|
|
374
|
+
return reject(R.recipient, 'payTo receives an unexpected asset');
|
|
375
|
+
outputsToPayTo += 1;
|
|
376
|
+
paidToPayTo += BigInt(current.amount);
|
|
377
|
+
} else if (current.address !== payer) {
|
|
378
|
+
return reject(R.recipient, 'Outputs may only go to payTo or back to the payer');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
outputTotals[asset] = total;
|
|
382
|
+
}
|
|
383
|
+
if (outputsToPayTo !== 1) return reject(R.recipient, 'Exactly one output to payTo is required');
|
|
384
|
+
if (paidToPayTo !== expect.amount) return reject(R.amount, 'Amount paid to payTo does not match');
|
|
385
|
+
return accept({ inputs, outputTotals });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** A transfer input (unit, message_index, output_index), or undefined for anything else. */
|
|
389
|
+
function transferInput(input: unknown, asset: string): InputRef | undefined {
|
|
390
|
+
if (
|
|
391
|
+
!isPlainObject(input) ||
|
|
392
|
+
!hasOnlyKeys(input, INPUT_KEYS) ||
|
|
393
|
+
!isHash(input['unit']) ||
|
|
394
|
+
!isIndex(input['message_index']) ||
|
|
395
|
+
!isIndex(input['output_index'])
|
|
396
|
+
) {
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
asset,
|
|
401
|
+
unit: input['unit'],
|
|
402
|
+
messageIndex: input['message_index'],
|
|
403
|
+
outputIndex: input['output_index'],
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Length of one base64 secp256k1 signature, as counted by obyte.js. */
|
|
408
|
+
const SIGNATURE_SIZE = 88;
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Commissions ocore expects for an already SIGNED single-author unit (v4,
|
|
412
|
+
* sizes counted with keys). obyte.js `getHeadersSize` is meant for the
|
|
413
|
+
* unsigned unit during compose (authentifiers hold a path placeholder) and
|
|
414
|
+
* adds SIGNATURE_SIZE itself; on a signed unit the real signature is already
|
|
415
|
+
* counted, so it is subtracted. Verified against testnet unit DxPSVW8p…
|
|
416
|
+
* (headers 452, payload 432).
|
|
417
|
+
*/
|
|
418
|
+
export function expectedCommissions(unit: object): { headers: number; payload: number } {
|
|
419
|
+
return {
|
|
420
|
+
headers: getHeadersSize(unit, true) - SIGNATURE_SIZE,
|
|
421
|
+
payload: getTotalPayloadSize(unit, true),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function compareOutputs(
|
|
426
|
+
a: { address: string; amount: number },
|
|
427
|
+
b: { address: string; amount: number },
|
|
428
|
+
): number {
|
|
429
|
+
if (a.address !== b.address) return a.address < b.address ? -1 : 1;
|
|
430
|
+
return a.amount - b.amount;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** The data message is exactly `{x402: {binding, quoteId}}` of this operation (undefined when it is). */
|
|
434
|
+
function checkBinding(payload: unknown, expect: ExactExpectation): Verdict<never> | undefined {
|
|
435
|
+
const R = EXACT_REASONS;
|
|
436
|
+
if (!isPlainObject(payload) || !hasOnlyKeys(payload, new Set(['x402']))) {
|
|
437
|
+
return reject(R.binding, 'Data message must be exactly {x402: {binding, quoteId}}');
|
|
438
|
+
}
|
|
439
|
+
const x402 = payload['x402'];
|
|
440
|
+
if (!isPlainObject(x402) || !hasOnlyKeys(x402, new Set(['binding', 'quoteId']))) {
|
|
441
|
+
return reject(R.binding, 'Data message must be exactly {x402: {binding, quoteId}}');
|
|
442
|
+
}
|
|
443
|
+
if (x402['binding'] !== expect.binding || x402['quoteId'] !== expect.quoteId) {
|
|
444
|
+
return reject(R.binding, 'Signed binding does not match this operation');
|
|
445
|
+
}
|
|
446
|
+
return undefined;
|
|
447
|
+
}
|