@orangecheck/agent-core 2.1.0 → 2.3.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/src/scope.test.ts CHANGED
@@ -204,3 +204,28 @@ describe('ordered-op values must be decimal integers (SPEC §7.4)', () => {
204
204
  expect(isSubScope(exercised, parseScope('ln:send(max_sats<=0)'))).toBe(false);
205
205
  });
206
206
  });
207
+
208
+ // oc-pledge-protocol SPEC §7.3 defines pledge:create and its constraint keys.
209
+ describe('pledge:create is a registered scope', () => {
210
+ for (const s of [
211
+ 'pledge:create',
212
+ 'pledge:create(max_bond_sats=2000000)',
213
+ 'pledge:create(mechanism=vote_resolves)',
214
+ 'pledge:create(counterparty=bc1qalice000000000000000000000000000000000)',
215
+ 'pledge:create(max_bond_sats=2000000,mechanism=vote_resolves,counterparty=bc1qalice000000000000000000000000000000000)',
216
+ ]) {
217
+ it(`strict mode accepts ${s}`, () => {
218
+ expect(() => validateScope(parseScope(s), { mode: 'strict' })).not.toThrow();
219
+ });
220
+ }
221
+
222
+ it('strict mode still rejects an unregistered pledge:create key', () => {
223
+ expect(() => validateScope(parseScope('pledge:create(foo=1)'), { mode: 'strict' })).toThrow(ScopeParseError);
224
+ });
225
+
226
+ it('max_bond_sats is an ordered numeric bound', () => {
227
+ const granted = parseScope('pledge:create(max_bond_sats<=2000000)');
228
+ expect(isSubScope(parseScope('pledge:create(max_bond_sats=500000)'), granted)).toBe(true);
229
+ expect(isSubScope(parseScope('pledge:create(max_bond_sats=3000000)'), granted)).toBe(false);
230
+ });
231
+ });
package/src/scope.ts CHANGED
@@ -34,6 +34,8 @@ export const REGISTERED_SCOPES: Record<string, { keys: string[] }> = {
34
34
  'http:request': { keys: ['origin', 'method', 'max_rps', 'max_bytes_out'] },
35
35
  'ln:send': { keys: ['max_sats', 'node', 'max_fee_sats'] },
36
36
  'mcp:invoke': { keys: ['server', 'tool', 'max_invocations'] },
37
+ // oc-pledge-protocol SPEC §7.3.
38
+ 'pledge:create': { keys: ['max_bond_sats', 'mechanism', 'counterparty'] },
37
39
  };
38
40
 
39
41
  /** Keys whose values are compared numerically for sub-scope ordering. */
@@ -46,6 +48,7 @@ const NUMERIC_KEYS = new Set<string>([
46
48
  'max_sats',
47
49
  'max_fee_sats',
48
50
  'max_invocations',
51
+ 'max_bond_sats',
49
52
  'kind',
50
53
  ]);
51
54
 
package/src/verify.ts CHANGED
@@ -298,8 +298,19 @@ export interface VerifyActionInput extends VerifyBase {
298
298
  */
299
299
  maxChainDepth?: number;
300
300
  content?: Uint8Array;
301
- verifyOtsAnchor?: (proofB64: string, blockHeight: number, blockHash: string) => Promise<boolean>;
302
- /** If action and revocation are both OTS-anchored, pass a function that returns the comparable block height of each via proof parsing. Defaults: use envelope.ots.block_height. */
301
+ /**
302
+ * Verify an OTS proof commits `envelopeId` into the block at `blockHeight`.
303
+ * Same shape as @orangecheck/stamp-core. An anchor is used for revocation
304
+ * priority (SPEC §9.3) only when this returns true; without it every
305
+ * anchor is treated as absent.
306
+ */
307
+ verifyOtsAnchor?: (
308
+ proofB64: string,
309
+ blockHeight: number,
310
+ blockHash: string,
311
+ envelopeId: string
312
+ ) => Promise<boolean>;
313
+ /** If action and revocation are both OTS-anchored, pass a function that returns the comparable block height of each via proof parsing. Defaults: use envelope.ots.block_height. The height is still checked by verifyOtsAnchor. */
303
314
  resolveAnchorBlockHeight?: (env: ActionEnvelope | RevocationEnvelope) => number | null;
304
315
  /**
305
316
  * v1.2 private-scope decryption key. Applied to the root delegation AND
@@ -445,6 +456,14 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
445
456
  'in the chain) or an explicit skipRevocationCheck:true',
446
457
  );
447
458
  }
459
+ // Anchor heights only count once the proof is verified against the
460
+ // envelope's own id; `ots` is outside the signature.
461
+ let actionAnchor: number | null;
462
+ try {
463
+ actionAnchor = await verifiedAnchorHeight(a, input);
464
+ } catch (e) {
465
+ return err('E_MALFORMED', `anchor verifier threw: ${(e as Error).message}`);
466
+ }
448
467
  if (input.revocations && input.revocations.length > 0) {
449
468
  const allLinks: ChainLink[] = [rootHydrated, ...hydratedChain];
450
469
  for (const link of allLinks) {
@@ -460,9 +479,13 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
460
479
  skipSignatureVerification: input.skipSignatureVerification,
461
480
  });
462
481
  if (!rr.ok) continue; // malformed revocations don't affect the action
463
- const effective = effectiveRevocationTime(rev, input.resolveAnchorBlockHeight);
464
- const actionTime = actionEffectiveTime(a, input.resolveAnchorBlockHeight);
465
- if (compareTimes(effective, actionTime) <= 0) {
482
+ let revAnchor: number | null;
483
+ try {
484
+ revAnchor = await verifiedAnchorHeight(rev, input);
485
+ } catch (e) {
486
+ return err('E_MALFORMED', `anchor verifier threw: ${(e as Error).message}`);
487
+ }
488
+ if (revocationPrecedes(rev, revAnchor, a, actionAnchor)) {
466
489
  return err('E_REVOKED', `chain link ${link.id} was revoked by ${rev.id} before action was signed`);
467
490
  }
468
491
  }
@@ -489,14 +512,7 @@ export async function verifyAction(input: VerifyActionInput): Promise<VerifyActi
489
512
  if (h === null || hash === null) {
490
513
  return err('E_MALFORMED', 'confirmed OTS proof missing block_height or block_hash');
491
514
  }
492
- let verified = false;
493
- if (input.verifyOtsAnchor) {
494
- try {
495
- verified = await input.verifyOtsAnchor(a.ots.proof, h, hash);
496
- } catch (e) {
497
- return err('E_MALFORMED', `anchor verifier threw: ${(e as Error).message}`);
498
- }
499
- }
515
+ const verified = actionAnchor !== null;
500
516
  anchor = { status: 'confirmed', blockHeight: h, blockHash: hash, verified } as typeof anchor;
501
517
  }
502
518
 
@@ -597,11 +613,11 @@ export async function verifyRevocation(input: VerifyRevocationInput): Promise<Ve
597
613
  return err('E_DELEGATION_MISMATCH', `revocation.delegation_id (${env.delegation_id}) != delegation.id (${d.id})`);
598
614
  }
599
615
 
600
- // Signer must be authorized per delegation.revocation.holders.
616
+ // The principal may always revoke (SPEC §9.5). `revocation.holders` is not
617
+ // covered by the delegation signature, so it may only add the agent.
601
618
  const holders = d.revocation?.holders ?? ['principal'];
602
- const holderAddrs = new Set<string>();
603
- if (holders.includes('principal')) holderAddrs.add(d.principal.address);
604
- if (holders.includes('agent')) holderAddrs.add(d.agent.address);
619
+ const holderAddrs = new Set<string>([d.principal.address]);
620
+ if (Array.isArray(holders) && holders.includes('agent')) holderAddrs.add(d.agent.address);
605
621
  if (!holderAddrs.has(env.signer.address)) {
606
622
  return err('E_REVOKER_UNAUTHORIZED', `revocation signer ${env.signer.address} not in delegation holders`);
607
623
  }
@@ -901,40 +917,36 @@ export async function verifySubdelegation(
901
917
  // Time comparison for revocation vs action (SPEC §9.3)
902
918
  // ─────────────────────────────────────────────────────────────────────────────
903
919
 
904
- type EffectiveTime =
905
- | { kind: 'anchor'; blockHeight: number }
906
- | { kind: 'signed'; ms: number };
907
-
908
- function actionEffectiveTime(
909
- a: ActionEnvelope,
910
- resolve?: (env: ActionEnvelope | RevocationEnvelope) => number | null
911
- ): EffectiveTime {
912
- if (a.ots?.status === 'confirmed') {
913
- const h = resolve ? resolve(a) : a.ots.block_height;
914
- if (h !== null && h !== undefined) return { kind: 'anchor', blockHeight: h };
915
- }
916
- return { kind: 'signed', ms: new Date(a.signed_at).getTime() };
917
- }
918
-
919
- function effectiveRevocationTime(
920
- r: RevocationEnvelope,
921
- resolve?: (env: ActionEnvelope | RevocationEnvelope) => number | null
922
- ): EffectiveTime {
923
- if (r.ots?.status === 'confirmed') {
924
- const h = resolve ? resolve(r) : r.ots.block_height;
925
- if (h !== null && h !== undefined) return { kind: 'anchor', blockHeight: h };
926
- }
927
- return { kind: 'signed', ms: new Date(r.signed_at).getTime() };
920
+ /**
921
+ * The block height of an envelope's OTS anchor, or null unless the proof has
922
+ * been verified to commit this envelope's id at that height.
923
+ */
924
+ async function verifiedAnchorHeight(
925
+ env: ActionEnvelope | RevocationEnvelope,
926
+ input: Pick<VerifyActionInput, 'verifyOtsAnchor' | 'resolveAnchorBlockHeight'>
927
+ ): Promise<number | null> {
928
+ if (env.ots?.status !== 'confirmed' || !input.verifyOtsAnchor) return null;
929
+ const h = input.resolveAnchorBlockHeight ? input.resolveAnchorBlockHeight(env) : env.ots.block_height;
930
+ const hash = env.ots.block_hash;
931
+ if (h === null || h === undefined || !Number.isInteger(h) || !hash) return null;
932
+ return (await input.verifyOtsAnchor(env.ots.proof, h, hash, env.id)) ? h : null;
928
933
  }
929
934
 
930
- /** Returns <0 if a < b, 0 if equal, >0 if a > b. Anchored always beats signed-only. */
931
- function compareTimes(a: EffectiveTime, b: EffectiveTime): number {
932
- if (a.kind === 'anchor' && b.kind === 'anchor') return a.blockHeight - b.blockHeight;
933
- // If only one anchored, the anchored one is authoritative: an unanchored action cannot
934
- // prove priority against an anchored revocation, so the anchored side is treated as "earlier".
935
- if (a.kind === 'anchor') return -1;
936
- if (b.kind === 'anchor') return 1;
937
- return a.ms - b.ms;
935
+ /**
936
+ * True when the revocation takes effect at or before the action (SPEC §9.3).
937
+ * Only verified anchors count for priority. An action without one cannot prove
938
+ * priority over a revocation that claims an anchor; an anchored action against
939
+ * an unanchored revocation falls back to the signed times.
940
+ */
941
+ function revocationPrecedes(
942
+ rev: RevocationEnvelope,
943
+ revAnchor: number | null,
944
+ action: ActionEnvelope,
945
+ actionAnchor: number | null
946
+ ): boolean {
947
+ if (revAnchor !== null && actionAnchor !== null) return revAnchor <= actionAnchor;
948
+ if (actionAnchor === null && rev.ots?.status === 'confirmed') return true;
949
+ return new Date(rev.signed_at).getTime() <= new Date(action.signed_at).getTime();
938
950
  }
939
951
 
940
952
  // ─────────────────────────────────────────────────────────────────────────────