@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +15 -2
  3. package/dist/channel/definition.d.ts +38 -0
  4. package/dist/channel/definition.d.ts.map +1 -0
  5. package/dist/channel/definition.js +86 -0
  6. package/dist/channel/definition.js.map +1 -0
  7. package/dist/config/index.d.ts +31 -0
  8. package/dist/config/index.d.ts.map +1 -0
  9. package/dist/config/index.js +33 -0
  10. package/dist/config/index.js.map +1 -0
  11. package/dist/core/amounts.d.ts +35 -0
  12. package/dist/core/amounts.d.ts.map +1 -0
  13. package/dist/core/amounts.js +94 -0
  14. package/dist/core/amounts.js.map +1 -0
  15. package/dist/core/errors.d.ts +16 -0
  16. package/dist/core/errors.d.ts.map +1 -0
  17. package/dist/core/errors.js +18 -0
  18. package/dist/core/errors.js.map +1 -0
  19. package/dist/core/networks.d.ts +34 -0
  20. package/dist/core/networks.d.ts.map +1 -0
  21. package/dist/core/networks.js +50 -0
  22. package/dist/core/networks.js.map +1 -0
  23. package/dist/core/result.d.ts +17 -0
  24. package/dist/core/result.d.ts.map +1 -0
  25. package/dist/core/result.js +13 -0
  26. package/dist/core/result.js.map +1 -0
  27. package/dist/index.d.ts +8 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +8 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/logger/index.d.ts +76 -0
  32. package/dist/logger/index.d.ts.map +1 -0
  33. package/dist/logger/index.js +161 -0
  34. package/dist/logger/index.js.map +1 -0
  35. package/dist/signing/signed-message.d.ts +47 -0
  36. package/dist/signing/signed-message.d.ts.map +1 -0
  37. package/dist/signing/signed-message.js +102 -0
  38. package/dist/signing/signed-message.js.map +1 -0
  39. package/dist/unit/exact-unit.d.ts +94 -0
  40. package/dist/unit/exact-unit.d.ts.map +1 -0
  41. package/dist/unit/exact-unit.js +387 -0
  42. package/dist/unit/exact-unit.js.map +1 -0
  43. package/dist/wallet/index.d.ts +29 -0
  44. package/dist/wallet/index.d.ts.map +1 -0
  45. package/dist/wallet/index.js +106 -0
  46. package/dist/wallet/index.js.map +1 -0
  47. package/package.json +64 -3
  48. package/src/channel/definition.ts +114 -0
  49. package/src/config/index.ts +40 -0
  50. package/src/core/amounts.ts +116 -0
  51. package/src/core/errors.ts +22 -0
  52. package/src/core/networks.ts +81 -0
  53. package/src/core/result.ts +23 -0
  54. package/src/index.ts +58 -0
  55. package/src/logger/index.ts +234 -0
  56. package/src/signing/signed-message.ts +147 -0
  57. package/src/types/obyte-internal.d.ts +19 -0
  58. package/src/unit/exact-unit.ts +447 -0
  59. package/src/wallet/index.ts +130 -0
@@ -0,0 +1,114 @@
1
+ import obyte from 'obyte';
2
+ import { type Verdict, accept, isPlainObject, reject } from '../core/result.js';
3
+
4
+ /**
5
+ * AA payment channels (aa-channels-lib, base AA v1.1).
6
+ *
7
+ * Party A is the payee (seller, x402 `payTo`), party B the payer (client).
8
+ * A channel address is derived from its parameters, so binding a payment to a
9
+ * seller is a matter of recomputing the address (ADR-0003): parameters are
10
+ * taken from the on-chain definition, never trusted from a package alone.
11
+ */
12
+ export const CHANNEL_BASE_AA = '4IRUGAUUCAQ6QRQLG4SNOLOE6VW2STWZ';
13
+
14
+ export interface ChannelParams {
15
+ readonly addressA: string;
16
+ readonly addressB: string;
17
+ /** `'base'` for bytes, otherwise a 44-char asset id. */
18
+ readonly asset: string;
19
+ readonly salt?: string;
20
+ /** Dispute window in seconds. */
21
+ readonly timeout: number;
22
+ }
23
+
24
+ export type ChannelDefinition = readonly [
25
+ 'autonomous agent',
26
+ { readonly base_aa: string; readonly params: ChannelParams },
27
+ ];
28
+
29
+ /** obyte.js `isValidAddress` as a type guard; it also rejects non-strings. */
30
+ export function isAddress(value: unknown): value is string {
31
+ return typeof value === 'string' && obyte.utils.isValidAddress(value);
32
+ }
33
+
34
+ const ASSET_RE = /^[A-Za-z0-9+/]{43}=$/;
35
+
36
+ export function isAssetId(value: unknown): value is string {
37
+ return value === 'base' || (typeof value === 'string' && ASSET_RE.test(value));
38
+ }
39
+
40
+ export function validateChannelParams(params: unknown): Verdict<ChannelParams> {
41
+ if (!isPlainObject(params)) return reject('invalid_channel_params', 'Params must be an object');
42
+ const { addressA, addressB, asset, salt, timeout } = params;
43
+ const extra = Object.keys(params).filter(
44
+ (k) => !['addressA', 'addressB', 'asset', 'salt', 'timeout'].includes(k),
45
+ );
46
+ if (extra.length > 0)
47
+ return reject('invalid_channel_params', `Unexpected params: ${extra.join(', ')}`);
48
+ if (!isAddress(addressA) || !isAddress(addressB)) {
49
+ return reject('invalid_channel_params', 'addressA and addressB must be valid addresses');
50
+ }
51
+ if (addressA === addressB) return reject('invalid_channel_params', 'Parties must differ');
52
+ if (!isAssetId(asset)) return reject('invalid_channel_params', 'Invalid asset');
53
+ if (salt !== undefined && (typeof salt !== 'string' || salt.length === 0 || salt.length > 100)) {
54
+ return reject('invalid_channel_params', 'Invalid salt');
55
+ }
56
+ if (!Number.isSafeInteger(timeout) || (timeout as number) <= 0) {
57
+ return reject('invalid_channel_params', 'timeout must be a positive integer (seconds)');
58
+ }
59
+ return accept({
60
+ addressA,
61
+ addressB,
62
+ asset,
63
+ ...(salt === undefined ? {} : { salt: salt as string }),
64
+ timeout: timeout as number,
65
+ });
66
+ }
67
+
68
+ export function channelDefinition(params: ChannelParams): ChannelDefinition {
69
+ // Key order matters for nothing (JSON source string sorts keys) but is kept
70
+ // identical to aa-channels-lib for readability.
71
+ const p: ChannelParams = {
72
+ addressA: params.addressA,
73
+ addressB: params.addressB,
74
+ asset: params.asset,
75
+ ...(params.salt === undefined ? {} : { salt: params.salt }),
76
+ timeout: params.timeout,
77
+ };
78
+ return ['autonomous agent', { base_aa: CHANNEL_BASE_AA, params: p }];
79
+ }
80
+
81
+ export function channelAddress(params: ChannelParams): string {
82
+ return obyte.utils.getChash160(channelDefinition(params) as unknown as unknown[]);
83
+ }
84
+
85
+ /**
86
+ * Parses an on-chain AA definition and returns its channel parameters if it
87
+ * is an instance of the supported base AA whose address matches `address`.
88
+ */
89
+ export function parseChannelDefinition(
90
+ address: string,
91
+ definition: unknown,
92
+ ): Verdict<ChannelParams> {
93
+ if (
94
+ !Array.isArray(definition) ||
95
+ definition.length !== 2 ||
96
+ definition[0] !== 'autonomous agent' ||
97
+ !isPlainObject(definition[1])
98
+ ) {
99
+ return reject('invalid_channel_definition', 'Not a parameterized AA definition');
100
+ }
101
+ const body = definition[1];
102
+ if (body['base_aa'] !== CHANNEL_BASE_AA) {
103
+ return reject(
104
+ 'invalid_channel_definition',
105
+ 'AA is not an instance of the supported channel base AA',
106
+ );
107
+ }
108
+ const params = validateChannelParams(body['params']);
109
+ if (!params.ok) return params;
110
+ if (channelAddress(params.value) !== address) {
111
+ return reject('invalid_channel_definition', 'Definition does not match the channel address');
112
+ }
113
+ return params;
114
+ }
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod';
2
+ import { ObbyxError } from '../core/errors.js';
3
+
4
+ /**
5
+ * Configuration is validated with zod at startup; an invalid config stops the
6
+ * process with a list of *all* problems rather than failing later at runtime.
7
+ * The same schemas generate the configuration reference in the docs.
8
+ */
9
+ export class ConfigError extends ObbyxError {
10
+ readonly issues: readonly string[];
11
+
12
+ constructor(issues: readonly string[]) {
13
+ super('invalid_config', `Invalid configuration:\n - ${issues.join('\n - ')}`, {
14
+ details: { issues },
15
+ });
16
+ this.issues = issues;
17
+ }
18
+ }
19
+
20
+ export function loadConfig<S extends z.ZodType>(schema: S, source: unknown): z.output<S> {
21
+ const result = schema.safeParse(source);
22
+ if (!result.success) {
23
+ throw new ConfigError(
24
+ result.error.issues.map((issue) => {
25
+ const path = issue.path.length > 0 ? issue.path.join('.') : '(root)';
26
+ return `${path}: ${issue.message}`;
27
+ }),
28
+ );
29
+ }
30
+ return result.data;
31
+ }
32
+
33
+ /** Reusable env-var helpers. */
34
+ export const envBoolean = z
35
+ .enum(['1', '0', 'true', 'false', 'yes', 'no'])
36
+ .transform((v) => v === '1' || v === 'true' || v === 'yes');
37
+
38
+ export const envPort = z.coerce.number().int().min(1).max(65535);
39
+
40
+ export const logLevelSchema = z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']);
@@ -0,0 +1,116 @@
1
+ import { ObbyxError } from './errors.js';
2
+
3
+ /**
4
+ * Amounts are always integers in the asset's smallest units (invariant I5).
5
+ *
6
+ * - On the wire (x402 JSON) they are decimal strings.
7
+ * - Inside obbyx they are `bigint`.
8
+ * - They are converted to `number` only at the boundary with libraries that
9
+ * require it (ocore, obyte.js, AA formulas), via {@link toSafeNumber}.
10
+ * - Prices are never computed with floating point.
11
+ */
12
+ export type Amount = bigint;
13
+
14
+ /** Largest amount accepted anywhere: every amount must round-trip through JS `number`. */
15
+ export const MAX_AMOUNT: Amount = BigInt(Number.MAX_SAFE_INTEGER);
16
+
17
+ const AMOUNT_RE = /^(0|[1-9][0-9]*)$/;
18
+ const DISPLAY_RE = /^(0|[1-9][0-9]*)(\.([0-9]+))?$/;
19
+ const MAX_DECIMALS = 18;
20
+
21
+ export class AmountError extends ObbyxError {
22
+ constructor(message: string, details?: Record<string, unknown>) {
23
+ super('invalid_amount', message, details === undefined ? {} : { details });
24
+ }
25
+ }
26
+
27
+ function assertInRange(value: Amount, input: unknown): Amount {
28
+ if (value < 0n || value > MAX_AMOUNT) {
29
+ throw new AmountError('Amount is out of range', { input: String(input) });
30
+ }
31
+ return value;
32
+ }
33
+
34
+ function assertDecimals(decimals: number): void {
35
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > MAX_DECIMALS) {
36
+ throw new AmountError('Invalid number of decimals', { decimals });
37
+ }
38
+ }
39
+
40
+ /** Parses an atomic amount from its canonical decimal-string form ("0", "1500"). */
41
+ export function parseAmount(input: string): Amount {
42
+ if (typeof input !== 'string' || !AMOUNT_RE.test(input)) {
43
+ throw new AmountError('Amount must be a non-negative integer string without leading zeros', {
44
+ input: String(input),
45
+ });
46
+ }
47
+ return assertInRange(BigInt(input), input);
48
+ }
49
+
50
+ /** Accepts a safe non-negative integer `number` (e.g. from ocore) and returns a bigint. */
51
+ export function fromSafeNumber(input: number): Amount {
52
+ if (!Number.isSafeInteger(input) || input < 0) {
53
+ throw new AmountError('Amount must be a non-negative safe integer', { input });
54
+ }
55
+ return BigInt(input);
56
+ }
57
+
58
+ /** Canonical wire form. */
59
+ export function formatAmount(value: Amount): string {
60
+ return assertInRange(value, value).toString();
61
+ }
62
+
63
+ /** Converts for libraries that only accept `number`; throws instead of losing precision. */
64
+ export function toSafeNumber(value: Amount): number {
65
+ return Number(assertInRange(value, value));
66
+ }
67
+
68
+ export function addAmounts(a: Amount, b: Amount): Amount {
69
+ return assertInRange(a + b, `${a}+${b}`);
70
+ }
71
+
72
+ /** Subtraction that never goes below zero silently. */
73
+ export function subAmounts(a: Amount, b: Amount): Amount {
74
+ if (b > a) {
75
+ throw new AmountError('Amount subtraction would be negative', {
76
+ a: a.toString(),
77
+ b: b.toString(),
78
+ });
79
+ }
80
+ return a - b;
81
+ }
82
+
83
+ /**
84
+ * Converts a human-readable amount ("0.25") into atomic units.
85
+ * Rejects more fractional digits than the asset supports instead of rounding.
86
+ */
87
+ export function fromDisplay(input: string, decimals: number): Amount {
88
+ assertDecimals(decimals);
89
+ const match = typeof input === 'string' ? DISPLAY_RE.exec(input) : null;
90
+ if (!match) {
91
+ throw new AmountError('Display amount must look like "12" or "12.34"', {
92
+ input: String(input),
93
+ });
94
+ }
95
+ const whole = match[1] ?? '0';
96
+ const fraction = match[3] ?? '';
97
+ if (fraction.length > decimals) {
98
+ throw new AmountError('Display amount has more fractional digits than the asset allows', {
99
+ input,
100
+ decimals,
101
+ });
102
+ }
103
+ const atomic = BigInt(whole + fraction.padEnd(decimals, '0'));
104
+ return assertInRange(atomic, input);
105
+ }
106
+
107
+ /** Converts atomic units into a human-readable string without floating point. */
108
+ export function toDisplay(value: Amount, decimals: number): string {
109
+ assertDecimals(decimals);
110
+ const digits = formatAmount(value);
111
+ if (decimals === 0) return digits;
112
+ const padded = digits.padStart(decimals + 1, '0');
113
+ const whole = padded.slice(0, -decimals);
114
+ const fraction = padded.slice(-decimals).replace(/0+$/, '');
115
+ return fraction.length > 0 ? `${whole}.${fraction}` : whole;
116
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Base error for all obbyx packages.
3
+ *
4
+ * `code` is a stable machine-readable identifier. Payment-related errors use
5
+ * x402 reason codes (e.g. `invalid_exact_obyte_payload_signature`) so they can
6
+ * be mapped 1:1 to `invalidReason` / `errorReason` in facilitator responses.
7
+ */
8
+ export class ObbyxError extends Error {
9
+ readonly code: string;
10
+ readonly details: Readonly<Record<string, unknown>> | undefined;
11
+
12
+ constructor(
13
+ code: string,
14
+ message: string,
15
+ options: { cause?: unknown; details?: Record<string, unknown> } = {},
16
+ ) {
17
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
18
+ this.name = new.target.name;
19
+ this.code = code;
20
+ this.details = options.details;
21
+ }
22
+ }
@@ -0,0 +1,81 @@
1
+ import { ObbyxError } from './errors.js';
2
+
3
+ /**
4
+ * Obyte networks and their identifiers.
5
+ *
6
+ * - x402 v2 uses CAIP-2 identifiers. No `obyte` namespace exists in
7
+ * ChainAgnostic/namespaces yet, so `obyte:livenet` / `obyte:testnet` are
8
+ * provisional until agreed upstream (see docs/adr/0004-network-identifiers.md).
9
+ * - x402 v1 uses plain strings, so each network also has a v1 name.
10
+ *
11
+ * Every storage, lock and dedup key must include the network (invariant I4);
12
+ * use {@link networkKey} to build such keys.
13
+ */
14
+ export type ObyteNetworkName = 'livenet' | 'testnet';
15
+ export type Caip2 = `${string}:${string}`;
16
+
17
+ export interface ObyteNetwork {
18
+ readonly name: ObyteNetworkName;
19
+ readonly caip2: Caip2;
20
+ readonly v1Name: string;
21
+ readonly testnet: boolean;
22
+ readonly defaultHubUrl: string;
23
+ readonly explorerUrl: string;
24
+ }
25
+
26
+ export const OBYTE_CAIP_FAMILY = 'obyte:*' as const;
27
+
28
+ export const OBYTE_NETWORKS: Readonly<Record<ObyteNetworkName, ObyteNetwork>> = Object.freeze({
29
+ livenet: Object.freeze({
30
+ name: 'livenet',
31
+ caip2: 'obyte:livenet',
32
+ v1Name: 'obyte-livenet',
33
+ testnet: false,
34
+ defaultHubUrl: 'wss://obyte.org/bb',
35
+ explorerUrl: 'https://explorer.obyte.org',
36
+ }),
37
+ testnet: Object.freeze({
38
+ name: 'testnet',
39
+ caip2: 'obyte:testnet',
40
+ v1Name: 'obyte-testnet',
41
+ testnet: true,
42
+ defaultHubUrl: 'wss://obyte.org/bb-test',
43
+ explorerUrl: 'https://testnetexplorer.obyte.org',
44
+ }),
45
+ });
46
+
47
+ const BY_ID: ReadonlyMap<string, ObyteNetwork> = new Map(
48
+ Object.values(OBYTE_NETWORKS).flatMap((n) => [
49
+ [n.caip2, n],
50
+ [n.v1Name, n],
51
+ ]),
52
+ );
53
+
54
+ export class UnsupportedNetworkError extends ObbyxError {
55
+ constructor(id: string) {
56
+ super('invalid_network', `Unsupported Obyte network: ${id}`, { details: { network: id } });
57
+ }
58
+ }
59
+
60
+ /** Resolves a v2 CAIP-2 id or a v1 network name; throws for anything else. */
61
+ export function getNetwork(id: string): ObyteNetwork {
62
+ const network = BY_ID.get(id);
63
+ if (!network) throw new UnsupportedNetworkError(id);
64
+ return network;
65
+ }
66
+
67
+ export function isObyteNetwork(id: string): boolean {
68
+ return BY_ID.has(id);
69
+ }
70
+
71
+ /** Builds a key namespaced by network, e.g. `obyte:testnet:unit:<hash>`. */
72
+ export function networkKey(network: ObyteNetwork, ...parts: readonly string[]): string {
73
+ if (parts.length === 0 || parts.some((p) => p.length === 0)) {
74
+ throw new ObbyxError('invalid_key', 'networkKey requires non-empty parts');
75
+ }
76
+ return [network.caip2, ...parts].join(':');
77
+ }
78
+
79
+ export function explorerUnitUrl(network: ObyteNetwork, unit: string): string {
80
+ return `${network.explorerUrl}/${encodeURIComponent(unit)}`;
81
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Outcome of a check. Rejections carry a stable `reason` code (x402 style) and a
3
+ * human-readable message; they are expected outcomes, not exceptions.
4
+ */
5
+ export type Verdict<T> =
6
+ | { readonly ok: true; readonly value: T }
7
+ | { readonly ok: false; readonly reason: string; readonly message: string };
8
+
9
+ export function accept<T>(value: T): Verdict<T> {
10
+ return { ok: true, value };
11
+ }
12
+
13
+ export function reject<T = never>(reason: string, message: string): Verdict<T> {
14
+ return { ok: false, reason, message };
15
+ }
16
+
17
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
18
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
19
+ }
20
+
21
+ export function hasOnlyKeys(obj: Record<string, unknown>, allowed: ReadonlySet<string>): boolean {
22
+ return Object.keys(obj).every((k) => allowed.has(k));
23
+ }
package/src/index.ts ADDED
@@ -0,0 +1,58 @@
1
+ export { ObbyxError } from './core/errors.js';
2
+ export { type Verdict, accept, reject } from './core/result.js';
3
+ export {
4
+ type Amount,
5
+ AmountError,
6
+ MAX_AMOUNT,
7
+ addAmounts,
8
+ formatAmount,
9
+ fromDisplay,
10
+ fromSafeNumber,
11
+ parseAmount,
12
+ subAmounts,
13
+ toDisplay,
14
+ toSafeNumber,
15
+ } from './core/amounts.js';
16
+ export {
17
+ type Caip2,
18
+ type ObyteNetwork,
19
+ type ObyteNetworkName,
20
+ OBYTE_CAIP_FAMILY,
21
+ OBYTE_NETWORKS,
22
+ UnsupportedNetworkError,
23
+ explorerUnitUrl,
24
+ getNetwork,
25
+ isObyteNetwork,
26
+ networkKey,
27
+ } from './core/networks.js';
28
+ export {
29
+ SIGNED_PACKAGE_VERSIONS,
30
+ type SigDefinition,
31
+ type SignedPackage,
32
+ type VerifiedPackage,
33
+ isSigDefinition,
34
+ verifySignature,
35
+ verifySignedPackage,
36
+ } from './signing/signed-message.js';
37
+ export {
38
+ CHANNEL_BASE_AA,
39
+ type ChannelDefinition,
40
+ type ChannelParams,
41
+ channelAddress,
42
+ channelDefinition,
43
+ isAddress,
44
+ isAssetId,
45
+ parseChannelDefinition,
46
+ validateChannelParams,
47
+ } from './channel/definition.js';
48
+ export {
49
+ DEFAULT_MAX_UNIT_BYTES,
50
+ EXACT_REASONS,
51
+ EXACT_UNIT_VERSIONS,
52
+ type ExactExpectation,
53
+ type ExactUnitFacts,
54
+ type InputRef,
55
+ compareOutputs,
56
+ expectedCommissions,
57
+ validateExactUnit,
58
+ } from './unit/exact-unit.js';
@@ -0,0 +1,234 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { createHash } from 'node:crypto';
3
+ import { pino, type DestinationStream, type Level, type Logger, type LoggerOptions } from 'pino';
4
+
5
+ /**
6
+ * Unified logger for all obbyx packages and apps (plan §7).
7
+ *
8
+ * - Structured JSON (pino), human-readable output in dev via pino-pretty.
9
+ * - Correlation fields (requestId, traceId, binding, proofId, ...) are taken
10
+ * from an AsyncLocalStorage context, so every log line written while a
11
+ * payment is processed carries them automatically — in the seller middleware,
12
+ * the facilitator and the watcher alike.
13
+ * - Sensitive values (signatures, payment packages, signed units, API keys,
14
+ * challenges, private keys) are never written; they are replaced by a short
15
+ * SHA-256 fingerprint so they can still be correlated across logs.
16
+ * - Libraries never call `console`; they receive a Logger via DI and create
17
+ * per-module children with {@link moduleLogger}.
18
+ */
19
+ export type { Logger, Level } from 'pino';
20
+
21
+ export const LOG_LEVELS: readonly Level[] = ['fatal', 'error', 'warn', 'info', 'debug', 'trace'];
22
+
23
+ /** Correlation fields propagated through a payment flow. */
24
+ export interface LogContext {
25
+ requestId?: string;
26
+ traceId?: string;
27
+ binding?: string;
28
+ proofId?: string;
29
+ network?: string;
30
+ scheme?: string;
31
+ x402Version?: number;
32
+ sellerId?: string;
33
+ aa?: string;
34
+ seq?: string;
35
+ }
36
+
37
+ const contextStorage = new AsyncLocalStorage<Readonly<LogContext>>();
38
+
39
+ /**
40
+ * Runs `fn` with additional correlation fields. Nested calls merge with (and
41
+ * may override) the outer context.
42
+ */
43
+ export function withLogContext<T>(fields: LogContext, fn: () => T): T {
44
+ const parent = contextStorage.getStore();
45
+ return contextStorage.run(Object.freeze({ ...parent, ...stripUndefined(fields) }), fn);
46
+ }
47
+
48
+ export function getLogContext(): Readonly<LogContext> {
49
+ return contextStorage.getStore() ?? {};
50
+ }
51
+
52
+ function stripUndefined(fields: LogContext): LogContext {
53
+ return Object.fromEntries(
54
+ Object.entries(fields).filter(([, v]) => v !== undefined),
55
+ ) as LogContext;
56
+ }
57
+
58
+ /**
59
+ * Short, stable fingerprint of a sensitive value: `sha256:<12 hex>`.
60
+ * Use it to log *that* a value was seen without revealing it.
61
+ */
62
+ export function fingerprint(value: unknown): string {
63
+ const text = typeof value === 'string' ? value : stableStringify(value);
64
+ return `sha256:${createHash('sha256').update(text).digest('hex').slice(0, 12)}`;
65
+ }
66
+
67
+ function stableStringify(value: unknown): string {
68
+ if (value === undefined) return 'undefined';
69
+ return JSON.stringify(value, (_key, v: unknown) => {
70
+ if (typeof v === 'bigint') return v.toString();
71
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
72
+ return Object.fromEntries(
73
+ Object.entries(v as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)),
74
+ );
75
+ }
76
+ return v;
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Field names whose values are bearer-sensitive or secret. They are redacted at
82
+ * the top level and up to three levels deep. Naming convention: code that logs
83
+ * such data must use one of these field names (enforced by tests and review).
84
+ */
85
+ export const SENSITIVE_FIELDS: readonly string[] = [
86
+ 'authentifiers',
87
+ 'signedPackage',
88
+ 'signed_package',
89
+ 'sentByPeer',
90
+ 'paymentPayload',
91
+ 'paymentSignature',
92
+ 'xPayment',
93
+ 'joint',
94
+ 'signedUnit',
95
+ 'apiKey',
96
+ 'authorization',
97
+ 'cookie',
98
+ 'challengeSignature',
99
+ 'privateKey',
100
+ 'mnemonic',
101
+ 'seed',
102
+ 'password',
103
+ 'secret',
104
+ ];
105
+
106
+ const SENSITIVE_HEADERS: readonly string[] = [
107
+ 'payment-signature',
108
+ 'x-payment',
109
+ 'authorization',
110
+ 'cookie',
111
+ 'x-api-key',
112
+ ];
113
+
114
+ function redactPaths(): string[] {
115
+ const paths: string[] = [];
116
+ for (const field of SENSITIVE_FIELDS) {
117
+ paths.push(field, `*.${field}`, `*.*.${field}`, `*.*.*.${field}`);
118
+ }
119
+ for (const header of SENSITIVE_HEADERS) {
120
+ paths.push(`headers["${header}"]`, `*.headers["${header}"]`);
121
+ }
122
+ return paths;
123
+ }
124
+
125
+ export interface CreateLoggerOptions {
126
+ /** Base level. Default: `info`. */
127
+ level?: Level;
128
+ /** Per-module overrides, e.g. `{ 'x402.exact': 'debug', hub: 'trace' }`. */
129
+ moduleLevels?: Readonly<Record<string, Level>>;
130
+ /** Service name written into every line (`service` field). */
131
+ service?: string;
132
+ /** Pretty-print for local development. */
133
+ pretty?: boolean;
134
+ /** Custom destination (tests, files). Ignored when `pretty` is set. */
135
+ destination?: DestinationStream;
136
+ }
137
+
138
+ const moduleLevelsByLogger = new WeakMap<Logger, Readonly<Record<string, Level>>>();
139
+
140
+ export function createLogger(options: CreateLoggerOptions = {}): Logger {
141
+ const pinoOptions: LoggerOptions = {
142
+ level: options.level ?? 'info',
143
+ base: options.service === undefined ? {} : { service: options.service },
144
+ timestamp: pino.stdTimeFunctions.isoTime,
145
+ messageKey: 'msg',
146
+ formatters: { level: (label) => ({ level: label }) },
147
+ redact: { paths: redactPaths(), censor: (value) => redacted(value) },
148
+ serializers: { err: pino.stdSerializers.err },
149
+ mixin: () => ({ ...getLogContext() }),
150
+ };
151
+
152
+ const logger =
153
+ options.pretty === true
154
+ ? pino({ ...pinoOptions, transport: { target: 'pino-pretty', options: { colorize: true } } })
155
+ : options.destination === undefined
156
+ ? pino(pinoOptions)
157
+ : pino(pinoOptions, options.destination);
158
+
159
+ moduleLevelsByLogger.set(logger, options.moduleLevels ?? {});
160
+ return logger;
161
+ }
162
+
163
+ function redacted(value: unknown): string {
164
+ return value === undefined || value === null ? '[redacted]' : `[redacted ${fingerprint(value)}]`;
165
+ }
166
+
167
+ /**
168
+ * Child logger for a module (`x402.exact`, `hub`, `watcher.sync`, ...).
169
+ * Applies the most specific per-module level: `x402.exact.verify` inherits a
170
+ * level configured for `x402.exact` or `x402`.
171
+ */
172
+ export function moduleLogger(
173
+ root: Logger,
174
+ module: string,
175
+ bindings: Record<string, unknown> = {},
176
+ ): Logger {
177
+ const levels = findModuleLevels(root);
178
+ const level = resolveModuleLevel(module, levels);
179
+ const child = root.child({ module, ...bindings }, level === undefined ? {} : { level });
180
+ moduleLevelsByLogger.set(child, levels);
181
+ return child;
182
+ }
183
+
184
+ function findModuleLevels(logger: Logger): Readonly<Record<string, Level>> {
185
+ return moduleLevelsByLogger.get(logger) ?? {};
186
+ }
187
+
188
+ export function resolveModuleLevel(
189
+ module: string,
190
+ levels: Readonly<Record<string, Level>>,
191
+ ): Level | undefined {
192
+ const parts = module.split('.');
193
+ for (let i = parts.length; i > 0; i--) {
194
+ const level = levels[parts.slice(0, i).join('.')];
195
+ if (level !== undefined) return level;
196
+ }
197
+ return undefined;
198
+ }
199
+
200
+ /**
201
+ * Parses `LOG_LEVELS="x402.exact=debug,hub=trace"`. Invalid entries throw so
202
+ * misconfiguration is visible at startup instead of silently ignored.
203
+ */
204
+ export function parseModuleLevels(spec: string | undefined): Record<string, Level> {
205
+ const result: Record<string, Level> = {};
206
+ if (spec === undefined || spec.trim() === '') return result;
207
+ for (const entry of spec.split(',')) {
208
+ const [module, level] = entry.split('=').map((s) => s.trim());
209
+ if (!module || !level || !isLevel(level)) {
210
+ throw new Error(`Invalid LOG_LEVELS entry "${entry}" (expected module=level)`);
211
+ }
212
+ result[module] = level;
213
+ }
214
+ return result;
215
+ }
216
+
217
+ export function isLevel(value: string): value is Level {
218
+ return (LOG_LEVELS as readonly string[]).includes(value);
219
+ }
220
+
221
+ /** Builds a logger from standard env vars: LOG_LEVEL, LOG_LEVELS, LOG_PRETTY. */
222
+ export function createLoggerFromEnv(
223
+ service: string,
224
+ env: Readonly<Record<string, string | undefined>> = process.env,
225
+ ): Logger {
226
+ const level = env['LOG_LEVEL'] ?? 'info';
227
+ if (!isLevel(level)) throw new Error(`Invalid LOG_LEVEL "${level}"`);
228
+ return createLogger({
229
+ service,
230
+ level,
231
+ moduleLevels: parseModuleLevels(env['LOG_LEVELS']),
232
+ pretty: env['LOG_PRETTY'] === '1' || env['LOG_PRETTY'] === 'true',
233
+ });
234
+ }