@obbyx/obyte 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +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 +334 -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 +388 -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,388 @@
|
|
|
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
|
+
const R = EXACT_REASONS;
|
|
121
|
+
const unsupported = (why: string) => reject<ExactUnitFacts>(R.unsupported, why);
|
|
122
|
+
|
|
123
|
+
// --- structure & size ---------------------------------------------------
|
|
124
|
+
if (!isPlainObject(unit)) return unsupported('Unit must be an object');
|
|
125
|
+
let serialized: string;
|
|
126
|
+
try {
|
|
127
|
+
serialized = JSON.stringify(unit);
|
|
128
|
+
} catch {
|
|
129
|
+
return unsupported('Unit is not serializable');
|
|
130
|
+
}
|
|
131
|
+
if (serialized.length > (expect.maxUnitBytes ?? DEFAULT_MAX_UNIT_BYTES)) {
|
|
132
|
+
return reject(R.tooLarge, 'Unit exceeds the size limit');
|
|
133
|
+
}
|
|
134
|
+
if (!hasOnlyKeys(unit, UNIT_KEYS)) return unsupported('Unit has fields outside the profile');
|
|
135
|
+
|
|
136
|
+
// --- network --------------------------------------------------------------
|
|
137
|
+
const net = expect.network.name;
|
|
138
|
+
if (unit['version'] !== EXACT_UNIT_VERSIONS[net] || unit['alt'] !== ALT[net]) {
|
|
139
|
+
return reject(R.network, `Unit version/alt do not match ${expect.network.caip2}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- headers --------------------------------------------------------------
|
|
143
|
+
const parents = unit['parent_units'];
|
|
144
|
+
if (!Array.isArray(parents) || parents.length === 0 || !parents.every(isHash)) {
|
|
145
|
+
return unsupported('Invalid parent_units');
|
|
146
|
+
}
|
|
147
|
+
if (!isHash(unit['last_ball']) || !isHash(unit['last_ball_unit'])) {
|
|
148
|
+
return unsupported('Invalid last ball');
|
|
149
|
+
}
|
|
150
|
+
if (!Number.isSafeInteger(unit['timestamp'])) return unsupported('Invalid timestamp');
|
|
151
|
+
for (const fee of ['headers_commission', 'payload_commission'] as const) {
|
|
152
|
+
if (!Number.isSafeInteger(unit[fee]) || (unit[fee] as number) <= 0) {
|
|
153
|
+
return unsupported(`Invalid ${fee}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const fee of ['tps_fee', 'burn_fee', 'max_aa_responses'] as const) {
|
|
157
|
+
if (fee in unit && !isIndex(unit[fee])) return unsupported(`Invalid ${fee}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// --- unit hash --------------------------------------------------------------
|
|
161
|
+
if (!isHash(unit['unit'])) return reject(R.unitHash, 'Missing unit hash');
|
|
162
|
+
const messages = unit['messages'];
|
|
163
|
+
const authors = unit['authors'];
|
|
164
|
+
if (!Array.isArray(messages) || !Array.isArray(authors)) return unsupported('Malformed unit');
|
|
165
|
+
let unitHash: string;
|
|
166
|
+
try {
|
|
167
|
+
unitHash = getUnitHash(unit);
|
|
168
|
+
} catch {
|
|
169
|
+
return reject(R.unitHash, 'Unit cannot be hashed');
|
|
170
|
+
}
|
|
171
|
+
if (unitHash !== unit['unit']) return reject(R.unitHash, 'Unit hash does not match content');
|
|
172
|
+
|
|
173
|
+
// --- author, definition, signature -----------------------------------------
|
|
174
|
+
if (authors.length !== 1) return unsupported('Exactly one author is supported');
|
|
175
|
+
const author: unknown = authors[0];
|
|
176
|
+
if (!isPlainObject(author) || !hasOnlyKeys(author, AUTHOR_KEYS))
|
|
177
|
+
return unsupported('Malformed author');
|
|
178
|
+
const payer = author['address'];
|
|
179
|
+
if (!isAddress(payer)) return unsupported('Invalid author address');
|
|
180
|
+
|
|
181
|
+
let definition: SigDefinition;
|
|
182
|
+
let definitionFromLedger = false;
|
|
183
|
+
if ('definition' in author) {
|
|
184
|
+
if (!isSigDefinition(author['definition'])) {
|
|
185
|
+
return reject(R.definition, "Only ['sig', {pubkey}] author definitions are supported");
|
|
186
|
+
}
|
|
187
|
+
definition = author['definition'];
|
|
188
|
+
} else if (options.authorDefinition !== undefined) {
|
|
189
|
+
if (!isSigDefinition(options.authorDefinition)) {
|
|
190
|
+
return reject(R.definition, "Only ['sig', {pubkey}] author definitions are supported");
|
|
191
|
+
}
|
|
192
|
+
definition = options.authorDefinition;
|
|
193
|
+
definitionFromLedger = true;
|
|
194
|
+
} else {
|
|
195
|
+
return reject(
|
|
196
|
+
R.definitionRequired,
|
|
197
|
+
'Author definition is not embedded; fetch it from the ledger',
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (obyte.utils.getChash160([...definition]) !== payer) {
|
|
201
|
+
return reject(R.definition, 'Definition does not match the author address');
|
|
202
|
+
}
|
|
203
|
+
const authentifiers = author['authentifiers'];
|
|
204
|
+
if (!isPlainObject(authentifiers) || !hasOnlyKeys(authentifiers, new Set(['r']))) {
|
|
205
|
+
return reject(R.signature, 'Expected a single authentifier "r"');
|
|
206
|
+
}
|
|
207
|
+
if (!verifySignature(getUnitHashToSign(unit), authentifiers['r'], definition[1].pubkey)) {
|
|
208
|
+
return reject(R.signature, 'Signature is invalid');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- payer / recipient sanity -------------------------------------------------
|
|
212
|
+
if (payer === expect.payTo) return reject(R.payer, 'Payer and payTo must differ');
|
|
213
|
+
if (expect.facilitatorAddresses?.includes(payer)) {
|
|
214
|
+
return reject(R.payer, 'A facilitator address must never be the payer');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --- messages -----------------------------------------------------------------
|
|
218
|
+
let dataCount = 0;
|
|
219
|
+
const payments = new Map<string, Record<string, unknown>>();
|
|
220
|
+
for (const message of messages) {
|
|
221
|
+
if (!isPlainObject(message) || !hasOnlyKeys(message, MESSAGE_KEYS)) {
|
|
222
|
+
return unsupported('Malformed message');
|
|
223
|
+
}
|
|
224
|
+
if (message['payload_location'] !== 'inline' || !('payload' in message)) {
|
|
225
|
+
return unsupported('Only inline payloads are supported');
|
|
226
|
+
}
|
|
227
|
+
if (getBase64Hash(message['payload'], true) !== message['payload_hash']) {
|
|
228
|
+
return reject(R.payloadHash, 'payload_hash does not match payload');
|
|
229
|
+
}
|
|
230
|
+
const payload = message['payload'];
|
|
231
|
+
if (message['app'] === 'data') {
|
|
232
|
+
dataCount += 1;
|
|
233
|
+
const binding = checkBinding(payload, expect);
|
|
234
|
+
if (binding !== undefined) return binding;
|
|
235
|
+
} else if (message['app'] === 'payment') {
|
|
236
|
+
if (!isPlainObject(payload) || !hasOnlyKeys(payload, PAYMENT_KEYS)) {
|
|
237
|
+
return unsupported('Malformed payment');
|
|
238
|
+
}
|
|
239
|
+
const asset = 'asset' in payload ? payload['asset'] : 'base';
|
|
240
|
+
if (asset === 'base' && 'asset' in payload)
|
|
241
|
+
return unsupported('Bytes payment must omit asset');
|
|
242
|
+
if (!isAssetId(asset)) return unsupported('Invalid payment asset');
|
|
243
|
+
if (payments.has(asset)) return unsupported('One payment message per asset');
|
|
244
|
+
payments.set(asset, payload);
|
|
245
|
+
} else {
|
|
246
|
+
return unsupported('Only payment and data messages are supported');
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (dataCount !== 1)
|
|
250
|
+
return reject(R.binding, 'Exactly one data message with the binding is required');
|
|
251
|
+
if (!payments.has('base')) return unsupported('A bytes payment message is required');
|
|
252
|
+
const allowedAssets = new Set(['base', expect.asset]);
|
|
253
|
+
for (const asset of payments.keys()) {
|
|
254
|
+
if (!allowedAssets.has(asset)) return reject(R.asset, 'Payment in an unexpected asset');
|
|
255
|
+
}
|
|
256
|
+
if (!payments.has(expect.asset)) return reject(R.asset, 'No payment in the required asset');
|
|
257
|
+
|
|
258
|
+
// --- inputs & outputs -----------------------------------------------------------
|
|
259
|
+
const inputs: InputRef[] = [];
|
|
260
|
+
const seenInputs = new Set<string>();
|
|
261
|
+
const outputTotals: Record<string, bigint> = {};
|
|
262
|
+
let paidToPayTo = 0n;
|
|
263
|
+
let outputsToPayTo = 0;
|
|
264
|
+
for (const [asset, payment] of payments) {
|
|
265
|
+
const ins = payment['inputs'];
|
|
266
|
+
const outs = payment['outputs'];
|
|
267
|
+
if (!Array.isArray(ins) || ins.length === 0 || !Array.isArray(outs) || outs.length === 0) {
|
|
268
|
+
return unsupported('Payment needs inputs and outputs');
|
|
269
|
+
}
|
|
270
|
+
for (const input of ins) {
|
|
271
|
+
if (
|
|
272
|
+
!isPlainObject(input) ||
|
|
273
|
+
!hasOnlyKeys(input, INPUT_KEYS) ||
|
|
274
|
+
!isHash(input['unit']) ||
|
|
275
|
+
!isIndex(input['message_index']) ||
|
|
276
|
+
!isIndex(input['output_index'])
|
|
277
|
+
) {
|
|
278
|
+
return unsupported('Only transfer inputs are supported');
|
|
279
|
+
}
|
|
280
|
+
const key = `${input['unit']}:${input['message_index']}:${input['output_index']}`;
|
|
281
|
+
if (seenInputs.has(key)) return unsupported('Duplicate input');
|
|
282
|
+
seenInputs.add(key);
|
|
283
|
+
inputs.push({
|
|
284
|
+
asset,
|
|
285
|
+
unit: input['unit'],
|
|
286
|
+
messageIndex: input['message_index'],
|
|
287
|
+
outputIndex: input['output_index'],
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
let total = 0n;
|
|
291
|
+
let previous: { address: string; amount: number } | undefined;
|
|
292
|
+
for (const output of outs) {
|
|
293
|
+
if (
|
|
294
|
+
!isPlainObject(output) ||
|
|
295
|
+
!hasOnlyKeys(output, OUTPUT_KEYS) ||
|
|
296
|
+
!isAddress(output['address']) ||
|
|
297
|
+
!isPositiveAmount(output['amount'])
|
|
298
|
+
) {
|
|
299
|
+
return unsupported('Malformed output');
|
|
300
|
+
}
|
|
301
|
+
// ocore requires outputs sorted by address, then amount; duplicates are
|
|
302
|
+
// outside our profile.
|
|
303
|
+
const current = { address: output['address'], amount: output['amount'] };
|
|
304
|
+
if (previous !== undefined && compareOutputs(previous, current) >= 0) {
|
|
305
|
+
return unsupported('Outputs must be sorted by address and amount without duplicates');
|
|
306
|
+
}
|
|
307
|
+
previous = current;
|
|
308
|
+
const amount = BigInt(output['amount']);
|
|
309
|
+
total += amount;
|
|
310
|
+
if (output['address'] === expect.payTo) {
|
|
311
|
+
if (asset !== expect.asset)
|
|
312
|
+
return reject(R.recipient, 'payTo receives an unexpected asset');
|
|
313
|
+
outputsToPayTo += 1;
|
|
314
|
+
paidToPayTo += amount;
|
|
315
|
+
} else if (output['address'] !== payer) {
|
|
316
|
+
return reject(R.recipient, 'Outputs may only go to payTo or back to the payer');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
outputTotals[asset] = total;
|
|
320
|
+
}
|
|
321
|
+
if (outputsToPayTo !== 1) return reject(R.recipient, 'Exactly one output to payTo is required');
|
|
322
|
+
if (paidToPayTo !== expect.amount) return reject(R.amount, 'Amount paid to payTo does not match');
|
|
323
|
+
|
|
324
|
+
// --- commissions ------------------------------------------------------------------
|
|
325
|
+
const commissions = expectedCommissions(unit);
|
|
326
|
+
if (
|
|
327
|
+
commissions.headers !== unit['headers_commission'] ||
|
|
328
|
+
commissions.payload !== unit['payload_commission']
|
|
329
|
+
) {
|
|
330
|
+
return reject(R.commissions, 'Commissions do not match the unit size');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return accept({
|
|
334
|
+
unitHash,
|
|
335
|
+
payer,
|
|
336
|
+
inputs,
|
|
337
|
+
lastBallUnit: unit['last_ball_unit'],
|
|
338
|
+
parentUnits: parents,
|
|
339
|
+
timestamp: unit['timestamp'] as number,
|
|
340
|
+
outputTotals,
|
|
341
|
+
commissions,
|
|
342
|
+
definitionFromLedger,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Length of one base64 secp256k1 signature, as counted by obyte.js. */
|
|
347
|
+
const SIGNATURE_SIZE = 88;
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Commissions ocore expects for an already SIGNED single-author unit (v4,
|
|
351
|
+
* sizes counted with keys). obyte.js `getHeadersSize` is meant for the
|
|
352
|
+
* unsigned unit during compose (authentifiers hold a path placeholder) and
|
|
353
|
+
* adds SIGNATURE_SIZE itself; on a signed unit the real signature is already
|
|
354
|
+
* counted, so it is subtracted. Verified against testnet unit DxPSVW8p…
|
|
355
|
+
* (headers 452, payload 432).
|
|
356
|
+
*/
|
|
357
|
+
export function expectedCommissions(unit: object): { headers: number; payload: number } {
|
|
358
|
+
return {
|
|
359
|
+
headers: getHeadersSize(unit, true) - SIGNATURE_SIZE,
|
|
360
|
+
payload: getTotalPayloadSize(unit, true),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function compareOutputs(
|
|
365
|
+
a: { address: string; amount: number },
|
|
366
|
+
b: { address: string; amount: number },
|
|
367
|
+
): number {
|
|
368
|
+
if (a.address !== b.address) return a.address < b.address ? -1 : 1;
|
|
369
|
+
return a.amount - b.amount;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function checkBinding(
|
|
373
|
+
payload: unknown,
|
|
374
|
+
expect: ExactExpectation,
|
|
375
|
+
): Verdict<ExactUnitFacts> | undefined {
|
|
376
|
+
const R = EXACT_REASONS;
|
|
377
|
+
if (!isPlainObject(payload) || !hasOnlyKeys(payload, new Set(['x402']))) {
|
|
378
|
+
return reject(R.binding, 'Data message must be exactly {x402: {binding, quoteId}}');
|
|
379
|
+
}
|
|
380
|
+
const x402 = payload['x402'];
|
|
381
|
+
if (!isPlainObject(x402) || !hasOnlyKeys(x402, new Set(['binding', 'quoteId']))) {
|
|
382
|
+
return reject(R.binding, 'Data message must be exactly {x402: {binding, quoteId}}');
|
|
383
|
+
}
|
|
384
|
+
if (x402['binding'] !== expect.binding || x402['quoteId'] !== expect.quoteId) {
|
|
385
|
+
return reject(R.binding, 'Signed binding does not match this operation');
|
|
386
|
+
}
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import obyte from 'obyte';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { isAddress } from '../channel/definition.js';
|
|
8
|
+
import { ObbyxError } from '../core/errors.js';
|
|
9
|
+
import type { ObyteNetwork } from '../core/networks.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Local single-address wallet shared by the CLI, the seller watcher and the
|
|
13
|
+
* demo playground: one private key per network in
|
|
14
|
+
* `$OBBYX_HOME/wallet-<network>.json` (default ~/.obbyx), file mode 0600.
|
|
15
|
+
* Loading verifies the file (schema), its permissions, its network and that
|
|
16
|
+
* the key derives the stored address. The key never leaves this machine.
|
|
17
|
+
*/
|
|
18
|
+
export const walletFileSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
version: z.literal(1),
|
|
21
|
+
network: z.string(),
|
|
22
|
+
address: z.string().refine(isAddress, 'invalid Obyte address'),
|
|
23
|
+
wif: z.string().min(1),
|
|
24
|
+
createdAt: z.string(),
|
|
25
|
+
})
|
|
26
|
+
.strict();
|
|
27
|
+
|
|
28
|
+
export type WalletFile = z.infer<typeof walletFileSchema>;
|
|
29
|
+
|
|
30
|
+
export function obbyxHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
31
|
+
return env['OBBYX_HOME'] ?? join(homedir(), '.obbyx');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function walletPath(network: ObyteNetwork, env: NodeJS.ProcessEnv = process.env): string {
|
|
35
|
+
return join(obbyxHome(env), `wallet-${network.name}.json`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createWallet(
|
|
39
|
+
network: ObyteNetwork,
|
|
40
|
+
opts: { force?: boolean; env?: NodeJS.ProcessEnv } = {},
|
|
41
|
+
): WalletFile {
|
|
42
|
+
const path = walletPath(network, opts.env);
|
|
43
|
+
if (existsSync(path) && opts.force !== true) {
|
|
44
|
+
throw new ObbyxError(
|
|
45
|
+
'wallet_exists',
|
|
46
|
+
`A wallet already exists at ${path} (use --force to replace it)`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
let privateKey: Uint8Array;
|
|
50
|
+
let address: string;
|
|
51
|
+
for (;;) {
|
|
52
|
+
privateKey = randomBytes(32);
|
|
53
|
+
try {
|
|
54
|
+
address = addressOfKey(privateKey, network); // rejects the (astronomically unlikely) invalid scalar
|
|
55
|
+
break;
|
|
56
|
+
} catch {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const wallet: WalletFile = {
|
|
61
|
+
version: 1,
|
|
62
|
+
network: network.caip2,
|
|
63
|
+
address,
|
|
64
|
+
wif: obyte.utils.toWif(privateKey, network.testnet),
|
|
65
|
+
createdAt: new Date().toISOString(),
|
|
66
|
+
};
|
|
67
|
+
mkdirSync(obbyxHome(opts.env), { recursive: true, mode: 0o700 });
|
|
68
|
+
writeFileSync(path, `${JSON.stringify(wallet, null, 2)}\n`, { mode: 0o600 });
|
|
69
|
+
chmodSync(path, 0o600);
|
|
70
|
+
return wallet;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function loadWallet(
|
|
74
|
+
network: ObyteNetwork,
|
|
75
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
76
|
+
): WalletFile & { privateKey: Uint8Array } {
|
|
77
|
+
const path = walletPath(network, env);
|
|
78
|
+
if (!existsSync(path)) {
|
|
79
|
+
throw new ObbyxError(
|
|
80
|
+
'no_wallet',
|
|
81
|
+
`No wallet for ${network.caip2}. Run: obbyx init --network ${network.name}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if ((statSync(path).mode & 0o077) !== 0) {
|
|
85
|
+
throw new ObbyxError(
|
|
86
|
+
'wallet_permissions',
|
|
87
|
+
`${path} is readable by others; run: chmod 600 ${path}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
let raw: unknown;
|
|
91
|
+
try {
|
|
92
|
+
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
93
|
+
} catch {
|
|
94
|
+
throw new ObbyxError('wallet_corrupt', `${path} is not valid JSON`);
|
|
95
|
+
}
|
|
96
|
+
const parsed = walletFileSchema.safeParse(raw);
|
|
97
|
+
if (!parsed.success) {
|
|
98
|
+
throw new ObbyxError(
|
|
99
|
+
'wallet_corrupt',
|
|
100
|
+
`${path}: ${parsed.error.issues[0]?.message ?? 'invalid wallet file'}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const wallet = parsed.data;
|
|
104
|
+
if (wallet.network !== network.caip2) {
|
|
105
|
+
throw new ObbyxError('wallet_network', `${path} belongs to ${wallet.network}`);
|
|
106
|
+
}
|
|
107
|
+
let privateKey: Uint8Array;
|
|
108
|
+
try {
|
|
109
|
+
privateKey = obyte.utils.fromWif(wallet.wif, network.testnet).privateKey as Uint8Array;
|
|
110
|
+
} catch {
|
|
111
|
+
throw new ObbyxError('wallet_corrupt', `${path}: invalid key for ${network.caip2}`);
|
|
112
|
+
}
|
|
113
|
+
const address = addressOfKey(privateKey, network);
|
|
114
|
+
if (address !== wallet.address)
|
|
115
|
+
throw new ObbyxError('wallet_corrupt', `${path}: key does not match address`);
|
|
116
|
+
return { ...wallet, privateKey };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Address of a single-sig key, via obyte.js public API (signMessage reports its author). */
|
|
120
|
+
export function addressOfKey(privateKey: Uint8Array, network: ObyteNetwork): string {
|
|
121
|
+
const signed = obyte.utils.signMessage('obbyx', {
|
|
122
|
+
privateKey,
|
|
123
|
+
testnet: network.testnet,
|
|
124
|
+
}) as unknown as {
|
|
125
|
+
authors: { address: string }[];
|
|
126
|
+
};
|
|
127
|
+
const address = signed.authors[0]?.address;
|
|
128
|
+
if (address === undefined) throw new ObbyxError('wallet_corrupt', 'Cannot derive address');
|
|
129
|
+
return address;
|
|
130
|
+
}
|