@kushbitx/sdk 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -80,6 +80,16 @@ if (await canSignTypedData(report, request)) {
80
80
 
81
81
  `typedDataDigest(typedData)` returns the same digest on its own. Neither function uses the network or a key.
82
82
 
83
+ **Signed verdicts (0.1.7).** An ALLOW from Signature Check carries `signedVerdict`: an EIP-712 `SignatureVerdict` signed by a KushBitx key, valid at most 60 seconds. When it is present, `canSignTypedData` also verifies it: same `typedDataDigest` and signer, chainId 8453, ALLOW, not expired, valid signature. For full trust, pass the active signer addresses from https://kushbitx.com/.well-known/kushbitx-preflight-signers.json:
84
+
85
+ ```js
86
+ const signers = (await (await fetch('https://kushbitx.com/.well-known/kushbitx-preflight-signers.json')).json())
87
+ .signers.filter(s => s.status === 'active').map(s => s.address);
88
+ if (await canSignTypedData(report, request, Date.now(), { signers, requireSigned: true })) { /* sign now */ }
89
+ ```
90
+
91
+ `verifySignedVerdict(signedVerdict, { primaryType, signers })` checks any KushBitx signed verdict on its own. Always pass `primaryType`: a `SignatureVerdict` (Signature Check) is never a `PreflightVerdict` (Preflight). `recoverAddress(digest, signature)` is the dependency-free secp256k1 recovery it uses.
92
+
83
93
  Required checks per transaction type: `requiredChecks` in https://kushbitx.com/api/services.
84
94
 
85
95
  Current prices: https://kushbitx.com/api/services
@@ -117,6 +127,8 @@ import { KushBitxClient } from 'https://kushbitx.com/sdk/kushbitx.mjs';
117
127
  - Tools: https://kushbitx.com/#tools
118
128
  - SpendGuard: https://kushbitx.com/spendguard
119
129
  - Integration example: https://kushbitx.com/examples/agent.mjs
130
+ - Clodds skill (optional, external): [integrations/clodds/kushbitx-preflight](integrations/clodds/kushbitx-preflight). Copy the folder into .clodds/skills/ to run KushBitx Preflight before signing Base transactions. Not part of CloddsBot and not endorsed by its maintainers.
131
+ - Franklin PreSpend hook (optional, external): [integrations/franklin/kushbitx-token-risk](integrations/franklin/kushbitx-token-risk). Screens the token a Base0xSwap buys with KushBitx Token Risk before the swap runs. It sees token names, not the transaction; read its limits first. Not part of Franklin and not endorsed by BlockRun.
120
132
  - Contact: Admin@kushbitx.com
121
133
 
122
134
  ## Community contributions
package/kushbitx.d.ts CHANGED
@@ -133,11 +133,17 @@ export interface TypedData { domain: { name?: string; version?: string; chainId?
133
133
  /** Input of Signature Check (0.003 USDC). */
134
134
  export interface SignatureCheckInput { chainId: 8453; signer: Address; typedData: TypedData; expectedPayTo?: Address; expectedAmount?: string; expectedSpender?: Address }
135
135
  /** Signature Check report (fields used by canSignTypedData). */
136
- export interface SignatureCheckReport { service: 'signature-check'; verdict: 'ALLOW' | 'REVIEW' | 'BLOCK'; typedDataDigest: `0x${string}`; chainId: 8453; signer: Address; expiresAt: string; kind: string; reasonCodes: string[]; [k: string]: unknown }
136
+ export interface SignatureCheckReport { service: 'signature-check'; verdict: 'ALLOW' | 'REVIEW' | 'BLOCK'; typedDataDigest: `0x${string}`; chainId: 8453; signer: Address; expiresAt: string; kind: string; reasonCodes: string[]; signedVerdict?: SignedVerdict | null; signedVerdictNote?: string; [k: string]: unknown }
137
137
  /** EIP-712 digest of typed data, computed locally (no network). Equals typedDataDigest in a Signature Check report. Throws on malformed typed data. */
138
138
  export declare function typedDataDigest(typedData: TypedData): `0x${string}`;
139
- /** True only for ALLOW on this exact typed data, for this signer, before expiresAt. request.chainId and typedData.domain.chainId must both be 8453; anything else is false. */
140
- export declare function canSignTypedData(report: SignatureCheckReport | Record<string, unknown>, request: { chainId: number | string | bigint; signer: Address; typedData: TypedData }, now?: number): Promise<boolean>;
139
+ /** A KushBitx signed verdict (ALLOW only). Preflight: primaryType PreflightVerdict. Signature Check: primaryType SignatureVerdict. Never interchangeable. */
140
+ export interface SignedVerdict { scheme: "eip712"; primaryType?: "PreflightVerdict" | "SignatureVerdict"; keyId: string; signer: Address; rulesVersion: string; typedData: TypedData; digest: `0x${string}`; signature: `0x${string}`; verify?: string; [k: string]: unknown }
141
+ /** Address that produced a 65-byte r,s,v signature over a 32-byte digest, or null (high s and v other than 27/28 are rejected). No dependency, no network. */
142
+ export declare function recoverAddress(digest: string, signature: string): Address | null;
143
+ /** Verifies a KushBitx signedVerdict: domain KushBitx Preflight / 1 / 8453, primaryType, recomputed digest and signature. Pass signers (active addresses from /.well-known/kushbitx-preflight-signers.json) for full trust. Never throws. */
144
+ export declare function verifySignedVerdict(signedVerdict: SignedVerdict | unknown, options?: { primaryType?: "PreflightVerdict" | "SignatureVerdict"; signers?: string[] }): { valid: boolean; signer: Address | null; reason: string | null };
145
+ /** True only for ALLOW on this exact typed data, for this signer, before expiresAt. request.chainId and typedData.domain.chainId must both be 8453. When the report carries signedVerdict, it must verify too (SignatureVerdict, same digest and signer, not expired); options.signers restricts the key; options.requireSigned makes it mandatory. Anything else is false. */
146
+ export declare function canSignTypedData(report: SignatureCheckReport | Record<string, unknown>, request: { chainId: number | string | bigint; signer: Address; typedData: TypedData }, now?: number, options?: { signers?: string[]; requireSigned?: boolean }): Promise<boolean>;
141
147
 
142
148
  export declare class KushBitxClient {
143
149
  constructor(options?: ClientOptions);
package/kushbitx.mjs CHANGED
@@ -246,12 +246,77 @@ export function typedDataDigest(typedData) {
246
246
  return '0x' + Array.from(digest, b => b.toString(16).padStart(2, '0')).join('');
247
247
  }
248
248
 
249
+ // secp256k1 public-key recovery (no dependency), used only to verify KushBitx signed verdicts.
250
+ const SECP_P = 2n ** 256n - 2n ** 32n - 977n;
251
+ const SECP_N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
252
+ const SECP_G = [0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n, 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n];
253
+ const modp = (a, m = SECP_P) => { const r = a % m; return r >= 0n ? r : r + m; };
254
+ function modInv(a, m) { let [x, y, u, v] = [0n, 1n, m, modp(a, m)]; while (v !== 0n) { const q = u / v; [x, y] = [y, x - q * y]; [u, v] = [v, u - q * v]; } if (u !== 1n) throw new Error('no inverse'); return modp(x, m); }
255
+ function modPow(b, e, m) { let r = 1n; b = modp(b, m); while (e > 0n) { if (e & 1n) r = r * b % m; b = b * b % m; e >>= 1n; } return r; }
256
+ // Jacobian coordinates [X, Y, Z]; null is the point at infinity.
257
+ function jDouble(P) { if (!P || P[1] === 0n) return null; const [X, Y, Z] = P; const S = modp(4n * X * Y * Y), M = modp(3n * X * X), X3 = modp(M * M - 2n * S); return [X3, modp(M * (S - X3) - 8n * Y ** 4n), modp(2n * Y * Z)]; }
258
+ function jAdd(P, Q) {
259
+ if (!P) return Q; if (!Q) return P;
260
+ const [X1, Y1, Z1] = P, [X2, Y2, Z2] = Q, Z1s = modp(Z1 * Z1), Z2s = modp(Z2 * Z2);
261
+ const U1 = modp(X1 * Z2s), U2 = modp(X2 * Z1s), S1 = modp(Y1 * Z2s * Z2), S2 = modp(Y2 * Z1s * Z1);
262
+ if (U1 === U2) return S1 === S2 ? jDouble(P) : null;
263
+ const H = modp(U2 - U1), R = modp(S2 - S1), H2 = modp(H * H), H3 = modp(H2 * H), X3 = modp(R * R - H3 - 2n * U1 * H2);
264
+ return [X3, modp(R * (U1 * H2 - X3) - S1 * H3), modp(H * Z1 * Z2)];
265
+ }
266
+ function jMul(P, k) { let R = null, A = P; while (k > 0n) { if (k & 1n) R = jAdd(R, A); A = jDouble(A); k >>= 1n; } return R; }
267
+ function toAffine(P) { if (!P) return null; const zi = modInv(P[2], SECP_P), zi2 = modp(zi * zi); return [modp(P[0] * zi2), modp(P[1] * zi2 * zi)]; }
268
+ const hex32 = n => n.toString(16).padStart(64, '0');
269
+ /** Address that produced a 65-byte r,s,v signature over a 32-byte digest, or null. Rejects high s and v other than 27/28. */
270
+ export function recoverAddress(digestHex, signatureHex) {
271
+ try {
272
+ const sig = String(signatureHex).replace(/^0x/i, ''), dig = String(digestHex).replace(/^0x/i, '');
273
+ if (!/^[0-9a-f]{130}$/i.test(sig) || !/^[0-9a-f]{64}$/i.test(dig)) return null;
274
+ const r = BigInt('0x' + sig.slice(0, 64)), s = BigInt('0x' + sig.slice(64, 128)), v = parseInt(sig.slice(128), 16);
275
+ if ((v !== 27 && v !== 28) || r <= 0n || r >= SECP_N || s <= 0n || s > SECP_N / 2n) return null;
276
+ const y2 = modp(r ** 3n + 7n); let y = modPow(y2, (SECP_P + 1n) / 4n, SECP_P);
277
+ if (modp(y * y) !== y2) return null;
278
+ if ((y & 1n) !== BigInt(v - 27)) y = SECP_P - y;
279
+ const e = BigInt('0x' + dig) % SECP_N, rInv = modInv(r, SECP_N);
280
+ const Q = toAffine(jAdd(jMul([SECP_G[0], SECP_G[1], 1n], modp(-e * rInv, SECP_N)), jMul([r, y, 1n], modp(s * rInv, SECP_N))));
281
+ if (!Q) return null;
282
+ const hash = keccak256(hexBytes(hex32(Q[0]) + hex32(Q[1])));
283
+ return '0x' + Array.from(hash.slice(12), b => b.toString(16).padStart(2, '0')).join('');
284
+ } catch { return null; }
285
+ }
286
+
287
+ const VERDICT_PRIMARY_TYPES = ['PreflightVerdict', 'SignatureVerdict'];
288
+ /**
289
+ * Verifies a KushBitx signedVerdict (Preflight or Signature Check): the domain is KushBitx Preflight / 1 / 8453, the
290
+ * primaryType is the one expected, the EIP-712 digest recomputes, and the signature recovers to signedVerdict.signer.
291
+ * options.signers: addresses listed as active at /.well-known/kushbitx-preflight-signers.json; pass them for full trust.
292
+ * options.primaryType: 'PreflightVerdict' or 'SignatureVerdict' (always pass it; the two are never interchangeable).
293
+ * Returns { valid, signer, reason }. Never throws.
294
+ */
295
+ export function verifySignedVerdict(signedVerdict, options = {}) {
296
+ const fail = reason => ({ valid: false, signer: null, reason });
297
+ try {
298
+ if (!signedVerdict || typeof signedVerdict !== 'object') return fail('no signedVerdict');
299
+ const td = signedVerdict.typedData, d = td?.domain;
300
+ if (!td || !d || d.name !== 'KushBitx Preflight' || String(d.version) !== '1' || chainIdOf({ chainId: d.chainId }) !== BASE_CHAIN_ID) return fail('wrong domain');
301
+ if (!VERDICT_PRIMARY_TYPES.includes(td.primaryType) || (options.primaryType && td.primaryType !== options.primaryType)) return fail('wrong primaryType');
302
+ const digest = typedDataDigest(td);
303
+ if (String(signedVerdict.digest || '').toLowerCase() !== digest) return fail('digest does not match the typed data');
304
+ const signer = recoverAddress(digest, signedVerdict.signature);
305
+ if (!signer || signer !== String(signedVerdict.signer || '').toLowerCase()) return fail('bad signature');
306
+ if (Array.isArray(options.signers) && !options.signers.map(a => String(a).toLowerCase()).includes(signer)) return fail('signer not in the trusted list');
307
+ return { valid: true, signer, reason: null };
308
+ } catch { return fail('malformed signedVerdict'); }
309
+ }
310
+
249
311
  /**
250
312
  * True only for an ALLOW Signature Check report on this exact typed data, for this signer, on Base, before expiresAt.
251
313
  * request: { chainId: 8453 (stated), signer, typedData }. typedData.domain.chainId must also be 8453: a signature
252
314
  * without a chain in its domain could be replayed elsewhere, so it is never accepted. Anything else: do not sign.
315
+ * When the report carries signedVerdict, it must also verify: a SignatureVerdict for the same typedDataDigest and
316
+ * signer, chainId 8453, ALLOW, not expired, with a valid KushBitx signature. options.signers (active addresses from
317
+ * /.well-known/kushbitx-preflight-signers.json) restricts the key; options.requireSigned makes signedVerdict mandatory.
253
318
  */
254
- export async function canSignTypedData(report, request, now = Date.now()) {
319
+ export async function canSignTypedData(report, request, now = Date.now(), options = {}) {
255
320
  if (!report || typeof report !== 'object' || report.verdict !== 'ALLOW' || typeof report.typedDataDigest !== 'string') return false;
256
321
  if (!request || typeof request !== 'object' || request.chainId === undefined || request.chainId === null || chainIdOf(request) !== BASE_CHAIN_ID) return false;
257
322
  if (report.chainId !== undefined && Number(report.chainId) !== BASE_CHAIN_ID) return false;
@@ -259,9 +324,13 @@ export async function canSignTypedData(report, request, now = Date.now()) {
259
324
  if (domainChain === undefined || domainChain === null || chainIdOf({ chainId: domainChain }) !== BASE_CHAIN_ID) return false;
260
325
  if (typeof request.signer !== 'string' || typeof report.signer !== 'string' || request.signer.toLowerCase() !== report.signer.toLowerCase()) return false;
261
326
  if (!report.expiresAt || !(Date.parse(report.expiresAt) > now)) return false;
262
- try {
263
- return report.typedDataDigest.toLowerCase() === typedDataDigest(request.typedData);
264
- } catch {
265
- return false;
266
- }
327
+ let local;
328
+ try { local = typedDataDigest(request.typedData); } catch { return false; }
329
+ if (report.typedDataDigest.toLowerCase() !== local) return false;
330
+ const sv = report.signedVerdict;
331
+ if (sv === undefined || sv === null) return !options.requireSigned;
332
+ const m = sv.typedData?.message;
333
+ if (!m || String(m.typedDataDigest || '').toLowerCase() !== local || String(m.signer || '').toLowerCase() !== request.signer.toLowerCase()) return false;
334
+ if (Number(m.chainId) !== BASE_CHAIN_ID || Number(m.verdict) !== 1 || !(Number(m.expiresAt) * 1000 > now)) return false;
335
+ return verifySignedVerdict(sv, { primaryType: 'SignatureVerdict', signers: options.signers }).valid;
267
336
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kushbitx/sdk",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Zero-dependency ESM client for KushBitx AgentProof services on Base",
5
5
  "type": "module",
6
6
  "exports": {