@calimero-network/mero-js 24.1.3 → 24.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.
@@ -1,4 +1,4 @@
1
1
  export { RelayClient, IntentRefusedError } from './relay-client.js';
2
- export type { RelayClientConfig, RelayDescription, IntentResult, CreationDescription, CreateContextInput, CreatedContext, GovernanceDescription, GovernInput, GovernResult, FoundNamespaceInput, FoundedNamespace, } from './relay-client.js';
2
+ export type { RelayClientConfig, RelayExecutor, RelayDescription, IntentResult, CreationDescription, CreateContextInput, CreatedContext, GovernanceDescription, GovernInput, GovernResult, FoundNamespaceInput, FoundedNamespace, } from './relay-client.js';
3
3
  export { createMemoryNonceSource, createLocalStorageNonceSource, createRecoveringNonceSource, authorNonceLookup, WarrantNonceExhaustedError, } from './nonce-source.js';
4
4
  export type { NonceSource, WarrantNonceLookup, RecoveringNonceSourceOptions, } from './nonce-source.js';
@@ -18,10 +18,11 @@
18
18
  * # The three preconditions, and why they are checked in this order
19
19
  *
20
20
  * `describe()` answers the first two in one call, before anything is signed:
21
- * the relay's executor account (which the warrant must name) and whether it
22
- * holds `CAN_AUTHOR_ON_BEHALF` on the owning group. The third — that the
23
- * author's account is a member — only the group knows, and it surfaces as a
24
- * refusal.
21
+ * the relay's executor account and signing key and the release the context's
22
+ * group names (which the warrant must name), and whether it holds
23
+ * `CAN_AUTHOR_ON_BEHALF` on the owning group. The third,
24
+ * that the author's account is a member, only the group knows, and it surfaces
25
+ * as a refusal.
25
26
  *
26
27
  * Checking before signing is not politeness. A warrant consumes a nonce from a
27
28
  * monotonic per-device sequence, and one minted against the wrong executor is
@@ -42,9 +43,8 @@ export interface RelayClientConfig {
42
43
  /**
43
44
  * The account the relay writes as, hex — a warrant's `executor`.
44
45
  *
45
- * An account rather than a key, so one of the relay's processes rotating its
46
- * signing key does not void warrants already issued to it. Left unset, the
47
- * first `execute` learns it from {@link RelayClient.describe}.
46
+ * Only checked against what the relay answers: a warrant also names the
47
+ * relay's signing key and the release, which only discovery reports, so every write asks.
48
48
  */
49
49
  executorAccount?: string;
50
50
  /** The author's account, hex — whose consent the warrant carries. */
@@ -84,9 +84,9 @@ export interface RelayClientConfig {
84
84
  /**
85
85
  * Seconds a minted warrant stays valid. Defaults to 300.
86
86
  *
87
- * Checked by the relay against its own clock and deliberately never by peers,
88
- * so this bounds how long *this* request may sit in flight — not how long the
89
- * network will accept the delta.
87
+ * Checked by the relay against its own clock, and by peers against the
88
+ * delta's own signed stamp, never their clocks: this bounds how long *this*
89
+ * request may sit in flight before the relay runs it.
90
90
  */
91
91
  ttlSeconds?: number;
92
92
  /** Injected for tests and non-browser runtimes. Defaults to global `fetch`. */
@@ -101,19 +101,29 @@ export interface RelayClientConfig {
101
101
  */
102
102
  rateLimitRetries?: number;
103
103
  }
104
- /** What a relay says about its ability to run intents in one context. */
105
- export interface RelayDescription {
104
+ /** The relay a warrant names: its account, and the one key of it that may spend the warrant. */
105
+ export interface RelayExecutor {
106
106
  /** The account a warrant must name as `executor`, hex. */
107
107
  executorAccount: string;
108
+ /** The signing key a warrant must name as `executor_key`, hex. */
109
+ executorKey: string;
110
+ }
111
+ /** What a relay says about its ability to run intents in one context. */
112
+ export interface RelayDescription extends RelayExecutor {
108
113
  /** Whether the relay holds `CAN_AUTHOR_ON_BEHALF` on the owning group. */
109
114
  canAuthorOnBehalf: boolean;
110
115
  /** The group whose admin grants that capability, hex. */
111
116
  groupId: string;
117
+ /**
118
+ * The blob id of the release the group names, hex: what a warrant must pin
119
+ * as `release_bytecode_id`. Discovery answers 404 while the group names none.
120
+ */
121
+ releaseBytecodeId: string;
122
+ /** That release's semver, signed as the warrant's `release_version`. */
123
+ releaseVersion: string;
112
124
  }
113
125
  /** What a relay says about its ability to create contexts in one group. */
114
- export interface CreationDescription {
115
- /** The account a creation warrant must name as `executor`, hex. */
116
- executorAccount: string;
126
+ export interface CreationDescription extends RelayExecutor {
117
127
  /** The group, hex. */
118
128
  groupId: string;
119
129
  /**
@@ -156,9 +166,7 @@ export interface CreatedContext {
156
166
  memberPublicKey: string;
157
167
  }
158
168
  /** What a relay says about publishing governance ops for members of one group. */
159
- export interface GovernanceDescription {
160
- /** The account a governance warrant must name as `executor`, hex. */
161
- executorAccount: string;
169
+ export interface GovernanceDescription extends RelayExecutor {
162
170
  /** The group asked about, hex. */
163
171
  groupId: string;
164
172
  /**
@@ -192,11 +200,10 @@ export interface FoundNamespaceInput {
192
200
  */
193
201
  salt?: string;
194
202
  /**
195
- * The relay's account, hex: the warrant's `executor`. The namespace does not
196
- * exist yet, so there is nothing to ask `describeGovernance` about. Falls back
197
- * to the client's configured or already-learned `executorAccount`.
203
+ * The relay's account and node key (pinned, or from `resolveRelayNodeKey`). Never
204
+ * a discovery answer: that key is unauthenticated, and no namespace exists yet.
198
205
  */
199
- executorAccount?: string;
206
+ executor: RelayExecutor;
200
207
  /**
201
208
  * The namespace root's default capability mask (`MemberCapabilities` bits),
202
209
  * set right after founding. A namespace is founded with core's minimal
@@ -292,7 +299,6 @@ export declare class IntentRefusedError extends Error {
292
299
  }
293
300
  export declare class RelayClient {
294
301
  private readonly baseUrl;
295
- private executorAccount;
296
302
  private readonly config;
297
303
  private readonly fetchImpl;
298
304
  private readonly timeoutMs;
@@ -389,7 +395,7 @@ export declare class RelayClient {
389
395
  createContext(input: CreateContextInput): Promise<CreatedContext>;
390
396
  /**
391
397
  * Ask the relay whether it may publish governance ops for members of
392
- * `groupId`, and which account a warrant must name.
398
+ * `groupId`, and which account and key a warrant must name.
393
399
  */
394
400
  describeGovernance(groupId: string): Promise<GovernanceDescription>;
395
401
  /**
@@ -427,9 +433,7 @@ export declare class RelayClient {
427
433
  *
428
434
  * The executor cannot be learned from the relay first: the namespace does not
429
435
  * exist, so `describeGovernance` has nothing to answer about. It comes from
430
- * `input.executorAccount`, the configured one, or one an earlier call learned
431
- * (`describe`, `createContext`, `govern`), and without any of them this
432
- * throws before taking a nonce.
436
+ * `input.executor` only, and without it this throws before taking a nonce.
433
437
  * The nonce is spent in the new namespace's ledger.
434
438
  *
435
439
  * With `defaultCapabilities`, the founder then sets the namespace's default
@@ -440,11 +444,11 @@ export declare class RelayClient {
440
444
  * Refusals (`400`/`403`) come back as {@link IntentRefusedError}; a salt
441
445
  * that names an existing namespace is a `409`, an `HTTPError`.
442
446
  */
443
- foundNamespace(input?: FoundNamespaceInput): Promise<FoundedNamespace>;
447
+ foundNamespace(input: FoundNamespaceInput): Promise<FoundedNamespace>;
444
448
  private postGovernance;
445
449
  /**
446
- * The executor a warrant must name: the one the relay answered with, which a
447
- * configured account must agree with. Remembered for later calls.
450
+ * The relay's answer, checked against a configured account.
451
+ * Decoded here so a relay naming no key is refused before a nonce is taken.
448
452
  */
449
453
  private checkedExecutor;
450
454
  /**
@@ -18,16 +18,17 @@
18
18
  * # The three preconditions, and why they are checked in this order
19
19
  *
20
20
  * `describe()` answers the first two in one call, before anything is signed:
21
- * the relay's executor account (which the warrant must name) and whether it
22
- * holds `CAN_AUTHOR_ON_BEHALF` on the owning group. The third — that the
23
- * author's account is a member — only the group knows, and it surfaces as a
24
- * refusal.
21
+ * the relay's executor account and signing key and the release the context's
22
+ * group names (which the warrant must name), and whether it holds
23
+ * `CAN_AUTHOR_ON_BEHALF` on the owning group. The third,
24
+ * that the author's account is a member, only the group knows, and it surfaces
25
+ * as a refusal.
25
26
  *
26
27
  * Checking before signing is not politeness. A warrant consumes a nonce from a
27
28
  * monotonic per-device sequence, and one minted against the wrong executor is
28
29
  * unspendable: the number is gone and the write never happened.
29
30
  */
30
- import { signWarrant } from '../warrant/warrant.js';
31
+ import { releaseVersionBytes, signWarrant } from '../warrant/warrant.js';
31
32
  import { resolveSigner } from '../signer/signer.js';
32
33
  import { signCreationWarrant } from '../warrant/creation-warrant.js';
33
34
  import { signGovernanceWarrant } from '../warrant/governance-warrant.js';
@@ -85,7 +86,6 @@ export class RelayClient {
85
86
  constructor(config) {
86
87
  this.config = config;
87
88
  this.baseUrl = config.relayUrl.replace(/\/+$/, '');
88
- this.executorAccount = config.executorAccount;
89
89
  const injected = config.fetch;
90
90
  this.fetchImpl = injected
91
91
  ? (input, init) => injected(input, init)
@@ -138,9 +138,6 @@ export class RelayClient {
138
138
  */
139
139
  async describe(contextId) {
140
140
  const body = await this.json('GET', `/admin-api/contexts/${encodeURIComponent(contextId)}/intents`);
141
- // Learned, not merely returned: a client that called `describe` should not
142
- // then have to pass the account back in to `execute`.
143
- this.executorAccount = body.data.executorAccount;
144
141
  return body.data;
145
142
  }
146
143
  /**
@@ -151,7 +148,13 @@ export class RelayClient {
151
148
  * apply. Nothing accumulates and nothing is reusable.
152
149
  */
153
150
  async execute(contextId, method, argsJson = {}) {
154
- const executor = this.executorAccount ?? (await this.describe(contextId)).executorAccount;
151
+ // Asked on every write: the release moves with each upgrade of the group, and a
152
+ // warrant pinning the one before is refused. All of it is checked before the nonce.
153
+ const described = await this.describe(contextId);
154
+ const executor = this.checkedExecutor(described);
155
+ const releaseBytecodeId = hex(fromHex(described.releaseBytecodeId ?? '', "the relay's releaseBytecodeId", 32));
156
+ const releaseVersion = described.releaseVersion ?? '';
157
+ releaseVersionBytes(releaseVersion);
155
158
  // Resolved before the nonce, so a misconfigured key burns none.
156
159
  const signer = await this.authorSigner();
157
160
  const nonce = await this.config.nonces.next();
@@ -159,7 +162,10 @@ export class RelayClient {
159
162
  const warrant = await signWarrant({
160
163
  context: contextId,
161
164
  authorAccount: this.config.authorAccount,
162
- executor,
165
+ executor: executor.executorAccount,
166
+ executorKey: executor.executorKey,
167
+ releaseBytecodeId,
168
+ releaseVersion,
163
169
  method,
164
170
  argsJson,
165
171
  nonce,
@@ -224,7 +230,7 @@ export class RelayClient {
224
230
  `${described.groupId}; it needs CAN_CREATE_CONTEXT or admin ` +
225
231
  '(checked before signing — no nonce was spent)', false, 403);
226
232
  }
227
- const executor = this.checkedExecutor(described.executorAccount);
233
+ const executor = this.checkedExecutor(described);
228
234
  const initArgs = input.initArgs ?? {};
229
235
  // Resolved before the nonce, so a misconfigured key burns none.
230
236
  const signer = await this.authorSigner();
@@ -234,7 +240,8 @@ export class RelayClient {
234
240
  group: input.groupId,
235
241
  seed: input.seed,
236
242
  authorAccount: this.config.authorAccount,
237
- executor,
243
+ executor: executor.executorAccount,
244
+ executorKey: executor.executorKey,
238
245
  applicationId: input.applicationId,
239
246
  serviceName: input.serviceName,
240
247
  name: input.name,
@@ -252,7 +259,7 @@ export class RelayClient {
252
259
  }
253
260
  /**
254
261
  * Ask the relay whether it may publish governance ops for members of
255
- * `groupId`, and which account a warrant must name.
262
+ * `groupId`, and which account and key a warrant must name.
256
263
  */
257
264
  async describeGovernance(groupId) {
258
265
  const body = await this.json('GET', `/admin-api/groups/${encodeURIComponent(groupId)}/governance-intents`);
@@ -281,7 +288,7 @@ export class RelayClient {
281
288
  `${described.groupId}; an admin must admit it as a relay or grant it CAN_AUTHOR_ON_BEHALF ` +
282
289
  '(checked before signing, no nonce was spent)', false, 403);
283
290
  }
284
- const executor = this.checkedExecutor(described.executorAccount);
291
+ const executor = this.checkedExecutor(described);
285
292
  // Resolved before the nonce, so a misconfigured key burns none.
286
293
  const signer = await this.authorSigner();
287
294
  const nonce = await this.config.nonces.next();
@@ -290,7 +297,8 @@ export class RelayClient {
290
297
  scope: input.groupId,
291
298
  op: input.op,
292
299
  authorAccount: this.config.authorAccount,
293
- executor,
300
+ executor: executor.executorAccount,
301
+ executorKey: executor.executorKey,
294
302
  nonce,
295
303
  notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
296
304
  signer,
@@ -316,9 +324,7 @@ export class RelayClient {
316
324
  *
317
325
  * The executor cannot be learned from the relay first: the namespace does not
318
326
  * exist, so `describeGovernance` has nothing to answer about. It comes from
319
- * `input.executorAccount`, the configured one, or one an earlier call learned
320
- * (`describe`, `createContext`, `govern`), and without any of them this
321
- * throws before taking a nonce.
327
+ * `input.executor` only, and without it this throws before taking a nonce.
322
328
  * The nonce is spent in the new namespace's ledger.
323
329
  *
324
330
  * With `defaultCapabilities`, the founder then sets the namespace's default
@@ -329,14 +335,12 @@ export class RelayClient {
329
335
  * Refusals (`400`/`403`) come back as {@link IntentRefusedError}; a salt
330
336
  * that names an existing namespace is a `409`, an `HTTPError`.
331
337
  */
332
- async foundNamespace(input = {}) {
333
- const executor = input.executorAccount
334
- ? this.checkedExecutor(input.executorAccount)
335
- : this.executorAccount;
336
- if (!executor) {
337
- throw new Error("foundNamespace needs the relay's account: pass executorAccount or configure it " +
338
+ async foundNamespace(input) {
339
+ if (!input?.executor) {
340
+ throw new Error("foundNamespace needs the relay's account and signing key: pass executor " +
338
341
  '(checked before signing, no nonce was spent)');
339
342
  }
343
+ const executor = this.checkedExecutor(input.executor);
340
344
  const salt = input.salt
341
345
  ? hex(fromHex(input.salt, 'salt', 32))
342
346
  : hex(crypto.getRandomValues(new Uint8Array(32)));
@@ -360,7 +364,8 @@ export class RelayClient {
360
364
  scope: namespaceId,
361
365
  op,
362
366
  authorAccount: this.config.authorAccount,
363
- executor,
367
+ executor: executor.executorAccount,
368
+ executorKey: executor.executorKey,
364
369
  nonce,
365
370
  notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
366
371
  signer,
@@ -408,17 +413,20 @@ export class RelayClient {
408
413
  return body.data;
409
414
  }
410
415
  /**
411
- * The executor a warrant must name: the one the relay answered with, which a
412
- * configured account must agree with. Remembered for later calls.
416
+ * The relay's answer, checked against a configured account.
417
+ * Decoded here so a relay naming no key is refused before a nonce is taken.
413
418
  */
414
419
  checkedExecutor(answered) {
420
+ const executor = {
421
+ executorAccount: hex(fromHex(answered.executorAccount ?? '', "the relay's executorAccount", 32)),
422
+ executorKey: hex(fromHex(answered.executorKey ?? '', "the relay's executorKey", 32)),
423
+ };
415
424
  const configured = this.config.executorAccount;
416
- if (configured && configured.toLowerCase() !== answered.toLowerCase()) {
425
+ if (configured && configured.toLowerCase() !== executor.executorAccount) {
417
426
  throw new Error(`configured executorAccount ${configured} is not the relay's account ` +
418
- `${answered}; a warrant naming it would be unspendable`);
427
+ `${executor.executorAccount}; a warrant naming it would be unspendable`);
419
428
  }
420
- this.executorAccount = answered;
421
- return answered;
429
+ return executor;
422
430
  }
423
431
  /**
424
432
  * One request, waiting out a rate limit rather than failing the caller.
@@ -36,8 +36,10 @@ export interface CreationWarrantInput {
36
36
  seed?: string;
37
37
  /** The author's account, hex — whose consent this is, and who runs `init`. */
38
38
  authorAccount: string;
39
- /** The relay authorised to act, hex. An account, not a key. */
39
+ /** The relay's account, hex: the subject its authorship grant is held by. */
40
40
  executor: string;
41
+ /** The one signing key of `executor` that may carry it out, hex: discovery's `executorKey`. */
42
+ executorKey: string;
41
43
  /** The application the context is created with, hex (32 bytes). */
42
44
  applicationId: string;
43
45
  /**
@@ -103,6 +105,7 @@ export interface CreationWarrantFields {
103
105
  /** The author device key: the signer's public key. */
104
106
  deviceKey: string;
105
107
  executor: string;
108
+ executorKey: string;
106
109
  applicationId: string;
107
110
  /** `null` when absent — distinct from `""`. */
108
111
  serviceName: string | null;
@@ -129,6 +132,7 @@ export interface CreationPreimageParts {
129
132
  authorAccount: Uint8Array;
130
133
  deviceKey: Uint8Array;
131
134
  executor: Uint8Array;
135
+ executorKey: Uint8Array;
132
136
  applicationId: Uint8Array;
133
137
  serviceName: Uint8Array | null;
134
138
  name: Uint8Array | null;
@@ -51,6 +51,7 @@ export async function creationWarrantPreimage(p) {
51
51
  p.authorAccount,
52
52
  p.deviceKey,
53
53
  p.executor,
54
+ p.executorKey,
54
55
  p.applicationId,
55
56
  new Uint8Array([p.serviceName === null ? 0 : 1]),
56
57
  p.serviceName ?? new Uint8Array(0),
@@ -78,6 +79,7 @@ export async function signCreationWarrant(input) {
78
79
  : fromHex(input.seed, 'seed', 32);
79
80
  const authorAccount = fromHex(input.authorAccount, 'authorAccount', 32);
80
81
  const executor = fromHex(input.executor, 'executor', 32);
82
+ const executorKey = fromHex(input.executorKey, 'executorKey', 32);
81
83
  const applicationId = fromHex(input.applicationId, 'applicationId', 32);
82
84
  const serviceName = label(input.serviceName, 'serviceName');
83
85
  const name = label(input.name, 'name');
@@ -92,6 +94,7 @@ export async function signCreationWarrant(input) {
92
94
  authorAccount,
93
95
  deviceKey,
94
96
  executor,
97
+ executorKey,
95
98
  applicationId,
96
99
  serviceName,
97
100
  name,
@@ -102,7 +105,7 @@ export async function signCreationWarrant(input) {
102
105
  notAfter: input.notAfter,
103
106
  });
104
107
  const signature = checkedSignature(await signer.sign(preimage));
105
- const warrant = hex(concat(group, seed, authorAccount, deviceKey, executor, applicationId, borshOption(serviceName), borshOption(name), initHash, u32le(accountHeads.length), ...accountHeads, u32le(governanceFloor.length), ...governanceFloor, u64le(input.nonce), u64le(input.notAfter), signature));
108
+ const warrant = hex(concat(group, seed, authorAccount, deviceKey, executor, executorKey, applicationId, borshOption(serviceName), borshOption(name), initHash, u32le(accountHeads.length), ...accountHeads, u32le(governanceFloor.length), ...governanceFloor, u64le(input.nonce), u64le(input.notAfter), signature));
106
109
  return { warrant, seed: hex(seed) };
107
110
  }
108
111
  /**
@@ -157,6 +160,7 @@ export function parseCreationWarrant(warrant) {
157
160
  authorAccount: hex(take(32, 'authorAccount')),
158
161
  deviceKey: hex(take(32, 'deviceKey')),
159
162
  executor: hex(take(32, 'executor')),
163
+ executorKey: hex(take(32, 'executorKey')),
160
164
  applicationId: hex(take(32, 'applicationId')),
161
165
  serviceName: option('serviceName'),
162
166
  name: option('name'),
@@ -39,8 +39,10 @@ export interface GovernanceWarrantInput {
39
39
  op: GovernanceOp;
40
40
  /** The author's account, hex: whose authority the op is applied under. */
41
41
  authorAccount: string;
42
- /** The relay authorised to publish it, hex. An account, not a key. */
42
+ /** The relay's account, hex: the subject its authorship grant is held by. */
43
43
  executor: string;
44
+ /** The one signing key of `executor` that may publish it, hex: discovery's `executorKey`. */
45
+ executorKey: string;
44
46
  /**
45
47
  * Monotonic per author **device**, spent in a per-group ledger. The same
46
48
  * nonce source `RelayClient` uses for every other warrant is correct.
@@ -76,6 +78,7 @@ export interface GovernanceWarrantFields {
76
78
  /** The author device key: the signer's public key. */
77
79
  deviceKey: string;
78
80
  executor: string;
81
+ executorKey: string;
79
82
  opHash: string;
80
83
  accountHeads: string[];
81
84
  governanceFloor: string[];
@@ -95,6 +98,7 @@ export interface GovernancePreimageParts {
95
98
  authorAccount: Uint8Array;
96
99
  deviceKey: Uint8Array;
97
100
  executor: Uint8Array;
101
+ executorKey: Uint8Array;
98
102
  opHash: Uint8Array;
99
103
  accountHeads: Uint8Array[];
100
104
  governanceFloor: Uint8Array[];
@@ -53,6 +53,7 @@ export async function governanceWarrantPreimage(p) {
53
53
  p.authorAccount,
54
54
  p.deviceKey,
55
55
  p.executor,
56
+ p.executorKey,
56
57
  p.opHash,
57
58
  u64le(p.accountHeads.length),
58
59
  ...p.accountHeads,
@@ -70,6 +71,7 @@ export async function signGovernanceWarrant(input) {
70
71
  const scope = fromHex(input.scope, 'scope', 32);
71
72
  const authorAccount = fromHex(input.authorAccount, 'authorAccount', 32);
72
73
  const executor = fromHex(input.executor, 'executor', 32);
74
+ const executorKey = fromHex(input.executorKey, 'executorKey', 32);
73
75
  const accountHeads = citedHeads(input.accountHeads, 'accountHeads');
74
76
  const governanceFloor = citedHeads(input.governanceFloor, 'governanceFloor');
75
77
  const kind = kindByte(input.op.kind);
@@ -82,6 +84,7 @@ export async function signGovernanceWarrant(input) {
82
84
  authorAccount,
83
85
  deviceKey,
84
86
  executor,
87
+ executorKey,
85
88
  opHash,
86
89
  accountHeads,
87
90
  governanceFloor,
@@ -89,7 +92,7 @@ export async function signGovernanceWarrant(input) {
89
92
  notAfter: input.notAfter,
90
93
  });
91
94
  const signature = checkedSignature(await signer.sign(preimage));
92
- return hex(concat(scope, new Uint8Array([kind]), authorAccount, deviceKey, executor, opHash, u32le(accountHeads.length), ...accountHeads, u32le(governanceFloor.length), ...governanceFloor, u64le(input.nonce), u64le(input.notAfter), signature));
95
+ return hex(concat(scope, new Uint8Array([kind]), authorAccount, deviceKey, executor, executorKey, opHash, u32le(accountHeads.length), ...accountHeads, u32le(governanceFloor.length), ...governanceFloor, u64le(input.nonce), u64le(input.notAfter), signature));
93
96
  }
94
97
  /**
95
98
  * Decode a hex governance warrant back into its fields.
@@ -139,6 +142,7 @@ export function parseGovernanceWarrant(warrant) {
139
142
  authorAccount: hex(take(32, 'authorAccount')),
140
143
  deviceKey: hex(take(32, 'deviceKey')),
141
144
  executor: hex(take(32, 'executor')),
145
+ executorKey: hex(take(32, 'executorKey')),
142
146
  opHash: hex(take(32, 'opHash')),
143
147
  accountHeads: heads('accountHeads'),
144
148
  governanceFloor: heads('governanceFloor'),
@@ -42,24 +42,27 @@ export interface WarrantInput {
42
42
  context: string;
43
43
  /** The author's account, hex — whose consent this is. */
44
44
  authorAccount: string;
45
- /** The relay authorised to act, hex. An account, not a key. */
45
+ /** The relay's account, hex: the subject its authorship grant is held by. */
46
46
  executor: string;
47
47
  /**
48
- * The application build this warrant is signed against, hex (32 bytes).
49
- *
50
- * Pins the code rather than a version string, so a relay cannot wait for an
51
- * upgrade that widens what `method` does and then spend a warrant signed
52
- * against the narrower one. Read it from the context
53
- * (`GET /admin-api/contexts/{id}` → `applicationId`); a value guessed here
54
- * pins the wrong build, which is worse than not pinning.
48
+ * The one signing key of `executor` that may spend this warrant, hex: the
49
+ * relay's `executorKey` from discovery. Any other key of the account is refused.
50
+ */
51
+ executorKey: string;
52
+ /**
53
+ * The blob id of the release the context's group names, hex (32 bytes): the
54
+ * relay's `releaseBytecodeId` from discovery.
55
55
  *
56
- * Defaults to 32 zero bytes, matching `merod account sign-warrant`'s
57
- * `--app-version` default. Nothing verifies the field yet — core#3933 landed
58
- * the field set ahead of its enforcement so a signer is written once against
59
- * the final bytes — but a warrant minted with the default will be refused once
60
- * pinning lands, so pass the real value for anything meant to outlive it.
56
+ * Pins the code, so a relay cannot wait for an upgrade that widens what
57
+ * `method` does and then spend a warrant signed against the narrower one. The
58
+ * relay refuses a warrant pinning any release other than the one it runs.
59
+ */
60
+ releaseBytecodeId: string;
61
+ /**
62
+ * That release's semver, discovery's `releaseVersion`: signed, never compared,
63
+ * at most 256 bytes of UTF-8. Defaults to empty, as `merod account warrant` does.
61
64
  */
62
- appVersion?: string;
65
+ releaseVersion?: string;
63
66
  /**
64
67
  * The method the relay may run.
65
68
  *
@@ -144,6 +147,8 @@ export declare function signWarrant(input: WarrantInput): Promise<string>;
144
147
  * relay, far from the signer at fault. Internal.
145
148
  */
146
149
  export declare function checkedSignature(signature: Uint8Array): Uint8Array;
150
+ /** A release version as UTF-8, refused past what a node accepts. Shared with `RelayClient`; internal. */
151
+ export declare function releaseVersionBytes(version: string): Uint8Array;
147
152
  export declare function citedHeads(heads: string[] | undefined, label: string): Uint8Array[];
148
153
  /** A warrant's fields, as hex, decoded from the wire encoding. */
149
154
  export interface WarrantFields {
@@ -152,7 +157,10 @@ export interface WarrantFields {
152
157
  /** The author device key `signWarrant` derived from the secret. */
153
158
  deviceKey: string;
154
159
  executor: string;
155
- appVersion: string;
160
+ executorKey: string;
161
+ releaseBytecodeId: string;
162
+ /** UTF-8, as hex like `method`. */
163
+ releaseVersion: string;
156
164
  method: string;
157
165
  intentHash: string;
158
166
  accountHeads: string[];
@@ -167,11 +175,11 @@ export interface WarrantFields {
167
175
  * Decode a hex warrant back into its fields.
168
176
  *
169
177
  * Exists because v2's offsets are no longer constants: `method` is a string and
170
- * the head lists are vectors, so everything after `appVersion` moves with the
171
- * method's length. Anything reading a warrant by fixed offset — a test, a client
172
- * inspecting one it was handed — silently reads the wrong field instead of
173
- * failing, which is how the three call sites that did so passed while comparing
174
- * the method's length prefix to a nonce.
178
+ * the head lists are vectors, so everything after `releaseBytecodeId` moves with
179
+ * the release version's and the method's lengths. Anything reading a warrant by
180
+ * fixed offset - a test, a client inspecting one it was handed - silently reads
181
+ * the wrong field instead of failing, which is how the three call sites that did
182
+ * so passed while comparing the method's length prefix to a nonce.
175
183
  *
176
184
  * One copy of the layout, beside the encoder that produces it, so the two cannot
177
185
  * drift. Internal: not exported from the package root, because a client that
@@ -36,8 +36,8 @@ const INTENT_DOMAIN = new TextEncoder().encode('calimero.warrant.intent.v1');
36
36
  * any Ed25519 work, so minting past it produces a warrant nothing will spend.
37
37
  */
38
38
  const MAX_CITED_HEADS = 64;
39
- /** 32 zero bytes — the `appVersion` default. See {@link WarrantInput.appVersion}. */
40
- const UNPINNED_APP = '00'.repeat(32);
39
+ /** core's `MAX_WARRANT_RELEASE_VERSION_LEN`, in UTF-8 bytes; a node refuses a longer one before any signature work. */
40
+ const MAX_RELEASE_VERSION_LEN = 256;
41
41
  /**
42
42
  * The commitment a warrant carries in place of the intent itself.
43
43
  *
@@ -59,7 +59,9 @@ export async function signWarrant(input) {
59
59
  const context = fromHex(input.context, 'context', 32);
60
60
  const authorAccount = fromHex(input.authorAccount, 'authorAccount', 32);
61
61
  const executor = fromHex(input.executor, 'executor', 32);
62
- const appVersion = fromHex(input.appVersion ?? UNPINNED_APP, 'appVersion', 32);
62
+ const executorKey = fromHex(input.executorKey, 'executorKey', 32);
63
+ const releaseBytecodeId = fromHex(input.releaseBytecodeId, 'releaseBytecodeId', 32);
64
+ const releaseVersion = releaseVersionBytes(input.releaseVersion ?? '');
63
65
  const accountHeads = citedHeads(input.accountHeads, 'accountHeads');
64
66
  const governanceFloor = citedHeads(input.governanceFloor, 'governanceFloor');
65
67
  const signer = await resolveSigner(input.deviceSecret, input.signer, 'deviceSecret');
@@ -78,7 +80,9 @@ export async function signWarrant(input) {
78
80
  authorAccount,
79
81
  publicKey,
80
82
  executor,
81
- appVersion,
83
+ executorKey,
84
+ releaseBytecodeId,
85
+ releaseVersion,
82
86
  method,
83
87
  commitment,
84
88
  u64le(accountHeads.length),
@@ -91,7 +95,7 @@ export async function signWarrant(input) {
91
95
  const signature = checkedSignature(await signer.sign(preimage));
92
96
  // Borsh: a `String` is a u32 length then its UTF-8 bytes; a `Vec<[u8; 32]>` is
93
97
  // a u32 count then the elements. u32 HERE; u64 in the preimage above.
94
- return hex(concat(context, authorAccount, publicKey, executor, appVersion, u32le(method.length), method, commitment, u32le(accountHeads.length), ...accountHeads, u32le(governanceFloor.length), ...governanceFloor, nonce, notAfter, signature));
98
+ return hex(concat(context, authorAccount, publicKey, executor, executorKey, releaseBytecodeId, u32le(releaseVersion.length), releaseVersion, u32le(method.length), method, commitment, u32le(accountHeads.length), ...accountHeads, u32le(governanceFloor.length), ...governanceFloor, nonce, notAfter, signature));
95
99
  }
96
100
  /**
97
101
  * Decode a cited-head list, refusing one longer than a node will accept.
@@ -111,6 +115,17 @@ export function checkedSignature(signature) {
111
115
  }
112
116
  return signature;
113
117
  }
118
+ /** A release version as UTF-8, refused past what a node accepts. Shared with `RelayClient`; internal. */
119
+ export function releaseVersionBytes(version) {
120
+ const bytes = new TextEncoder().encode(version);
121
+ checkReleaseVersionLength(bytes.length);
122
+ return bytes;
123
+ }
124
+ function checkReleaseVersionLength(len) {
125
+ if (len > MAX_RELEASE_VERSION_LEN) {
126
+ throw new Error(`releaseVersion is ${len} bytes, over the ${MAX_RELEASE_VERSION_LEN} a node accepts`);
127
+ }
128
+ }
114
129
  export function citedHeads(heads, label) {
115
130
  const list = heads ?? [];
116
131
  if (list.length > MAX_CITED_HEADS) {
@@ -122,11 +137,11 @@ export function citedHeads(heads, label) {
122
137
  * Decode a hex warrant back into its fields.
123
138
  *
124
139
  * Exists because v2's offsets are no longer constants: `method` is a string and
125
- * the head lists are vectors, so everything after `appVersion` moves with the
126
- * method's length. Anything reading a warrant by fixed offset — a test, a client
127
- * inspecting one it was handed — silently reads the wrong field instead of
128
- * failing, which is how the three call sites that did so passed while comparing
129
- * the method's length prefix to a nonce.
140
+ * the head lists are vectors, so everything after `releaseBytecodeId` moves with
141
+ * the release version's and the method's lengths. Anything reading a warrant by
142
+ * fixed offset - a test, a client inspecting one it was handed - silently reads
143
+ * the wrong field instead of failing, which is how the three call sites that did
144
+ * so passed while comparing the method's length prefix to a nonce.
130
145
  *
131
146
  * One copy of the layout, beside the encoder that produces it, so the two cannot
132
147
  * drift. Internal: not exported from the package root, because a client that
@@ -146,7 +161,12 @@ export function parseWarrant(warrant) {
146
161
  const authorAccount = take(32);
147
162
  const deviceKey = take(32);
148
163
  const executor = take(32);
149
- const appVersion = take(32);
164
+ const executorKey = take(32);
165
+ const releaseBytecodeId = take(32);
166
+ const releaseVersionLen = u32At(o);
167
+ o += 4;
168
+ checkReleaseVersionLength(releaseVersionLen);
169
+ const releaseVersion = take(releaseVersionLen);
150
170
  const methodLen = u32At(o);
151
171
  o += 4;
152
172
  const method = take(methodLen);
@@ -168,7 +188,9 @@ export function parseWarrant(warrant) {
168
188
  authorAccount,
169
189
  deviceKey,
170
190
  executor,
171
- appVersion,
191
+ executorKey,
192
+ releaseBytecodeId,
193
+ releaseVersion,
172
194
  method,
173
195
  intentHash: intentHashField,
174
196
  accountHeads,