@calimero-network/mero-js 24.2.0 → 24.4.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.
@@ -18,8 +18,9 @@
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 and signing key (which the warrant must name)
22
- * and whether it holds `CAN_AUTHOR_ON_BEHALF` on the owning group. The third,
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,
23
24
  * that the author's account is a member, only the group knows, and it surfaces
24
25
  * as a refusal.
25
26
  *
@@ -43,7 +44,7 @@ export interface RelayClientConfig {
43
44
  * The account the relay writes as, hex — a warrant's `executor`.
44
45
  *
45
46
  * Only checked against what the relay answers: a warrant also names the
46
- * relay's signing key, which only discovery reports, so the first call asks.
47
+ * relay's signing key and the release, which only discovery reports, so every write asks.
47
48
  */
48
49
  executorAccount?: string;
49
50
  /** The author's account, hex — whose consent the warrant carries. */
@@ -83,9 +84,9 @@ export interface RelayClientConfig {
83
84
  /**
84
85
  * Seconds a minted warrant stays valid. Defaults to 300.
85
86
  *
86
- * Checked by the relay against its own clock and deliberately never by peers,
87
- * so this bounds how long *this* request may sit in flight — not how long the
88
- * 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.
89
90
  */
90
91
  ttlSeconds?: number;
91
92
  /** Injected for tests and non-browser runtimes. Defaults to global `fetch`. */
@@ -113,6 +114,13 @@ export interface RelayDescription extends RelayExecutor {
113
114
  canAuthorOnBehalf: boolean;
114
115
  /** The group whose admin grants that capability, hex. */
115
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;
116
124
  }
117
125
  /** What a relay says about its ability to create contexts in one group. */
118
126
  export interface CreationDescription extends RelayExecutor {
@@ -291,7 +299,6 @@ export declare class IntentRefusedError extends Error {
291
299
  }
292
300
  export declare class RelayClient {
293
301
  private readonly baseUrl;
294
- private executor;
295
302
  private readonly config;
296
303
  private readonly fetchImpl;
297
304
  private readonly timeoutMs;
@@ -440,7 +447,7 @@ export declare class RelayClient {
440
447
  foundNamespace(input: FoundNamespaceInput): Promise<FoundedNamespace>;
441
448
  private postGovernance;
442
449
  /**
443
- * The relay's answer, checked against a configured account and remembered.
450
+ * The relay's answer, checked against a configured account.
444
451
  * Decoded here so a relay naming no key is refused before a nonce is taken.
445
452
  */
446
453
  private checkedExecutor;
@@ -18,8 +18,9 @@
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 and signing key (which the warrant must name)
22
- * and whether it holds `CAN_AUTHOR_ON_BEHALF` on the owning group. The third,
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,
23
24
  * that the author's account is a member, only the group knows, and it surfaces
24
25
  * as a refusal.
25
26
  *
@@ -27,7 +28,7 @@
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';
@@ -137,9 +138,6 @@ export class RelayClient {
137
138
  */
138
139
  async describe(contextId) {
139
140
  const body = await this.json('GET', `/admin-api/contexts/${encodeURIComponent(contextId)}/intents`);
140
- // Learned, not merely returned: a client that called `describe` should not
141
- // then have to pass the executor back in to `execute`.
142
- this.checkedExecutor(body.data);
143
141
  return body.data;
144
142
  }
145
143
  /**
@@ -150,7 +148,13 @@ export class RelayClient {
150
148
  * apply. Nothing accumulates and nothing is reusable.
151
149
  */
152
150
  async execute(contextId, method, argsJson = {}) {
153
- const executor = this.executor ?? this.checkedExecutor(await this.describe(contextId));
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);
154
158
  // Resolved before the nonce, so a misconfigured key burns none.
155
159
  const signer = await this.authorSigner();
156
160
  const nonce = await this.config.nonces.next();
@@ -160,18 +164,15 @@ export class RelayClient {
160
164
  authorAccount: this.config.authorAccount,
161
165
  executor: executor.executorAccount,
162
166
  executorKey: executor.executorKey,
167
+ releaseBytecodeId,
168
+ releaseVersion,
163
169
  method,
164
170
  argsJson,
165
171
  nonce,
166
172
  notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
167
173
  signer,
168
174
  });
169
- const body = await this.json('POST', `/admin-api/contexts/${encodeURIComponent(contextId)}/intents`, { method, argsJson, warrant, authorProof: this.config.authorProof }).catch((err) => {
170
- // A relay that re-keyed refuses every warrant naming its old key, so ask again next time.
171
- if (err instanceof IntentRefusedError)
172
- this.executor = undefined;
173
- throw err;
174
- });
175
+ const body = await this.json('POST', `/admin-api/contexts/${encodeURIComponent(contextId)}/intents`, { method, argsJson, warrant, authorProof: this.config.authorProof });
175
176
  return { rootHash: body.data.rootHash, returns: body.data.returns ?? null };
176
177
  }
177
178
  /**
@@ -412,7 +413,7 @@ export class RelayClient {
412
413
  return body.data;
413
414
  }
414
415
  /**
415
- * The relay's answer, checked against a configured account and remembered.
416
+ * The relay's answer, checked against a configured account.
416
417
  * Decoded here so a relay naming no key is refused before a nonce is taken.
417
418
  */
418
419
  checkedExecutor(answered) {
@@ -425,7 +426,6 @@ export class RelayClient {
425
426
  throw new Error(`configured executorAccount ${configured} is not the relay's account ` +
426
427
  `${executor.executorAccount}; a warrant naming it would be unspendable`);
427
428
  }
428
- this.executor = executor;
429
429
  return executor;
430
430
  }
431
431
  /**
@@ -50,21 +50,19 @@ export interface WarrantInput {
50
50
  */
51
51
  executorKey: string;
52
52
  /**
53
- * The application build this warrant is signed against, hex (32 bytes).
53
+ * The blob id of the release the context's group names, hex (32 bytes): the
54
+ * relay's `releaseBytecodeId` from discovery.
54
55
  *
55
- * Pins the code rather than a version string, so a relay cannot wait for an
56
- * upgrade that widens what `method` does and then spend a warrant signed
57
- * against the narrower one. Read it from the context
58
- * (`GET /admin-api/contexts/{id}` → `applicationId`); a value guessed here
59
- * pins the wrong build, which is worse than not pinning.
60
- *
61
- * Defaults to 32 zero bytes, matching `merod account sign-warrant`'s
62
- * `--app-version` default. Nothing verifies the field yet — core#3933 landed
63
- * the field set ahead of its enforcement so a signer is written once against
64
- * the final bytes — but a warrant minted with the default will be refused once
65
- * 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.
66
64
  */
67
- appVersion?: string;
65
+ releaseVersion?: string;
68
66
  /**
69
67
  * The method the relay may run.
70
68
  *
@@ -149,6 +147,8 @@ export declare function signWarrant(input: WarrantInput): Promise<string>;
149
147
  * relay, far from the signer at fault. Internal.
150
148
  */
151
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;
152
152
  export declare function citedHeads(heads: string[] | undefined, label: string): Uint8Array[];
153
153
  /** A warrant's fields, as hex, decoded from the wire encoding. */
154
154
  export interface WarrantFields {
@@ -158,7 +158,9 @@ export interface WarrantFields {
158
158
  deviceKey: string;
159
159
  executor: string;
160
160
  executorKey: string;
161
- appVersion: string;
161
+ releaseBytecodeId: string;
162
+ /** UTF-8, as hex like `method`. */
163
+ releaseVersion: string;
162
164
  method: string;
163
165
  intentHash: string;
164
166
  accountHeads: string[];
@@ -173,11 +175,11 @@ export interface WarrantFields {
173
175
  * Decode a hex warrant back into its fields.
174
176
  *
175
177
  * Exists because v2's offsets are no longer constants: `method` is a string and
176
- * the head lists are vectors, so everything after `appVersion` moves with the
177
- * method's length. Anything reading a warrant by fixed offset — a test, a client
178
- * inspecting one it was handed — silently reads the wrong field instead of
179
- * failing, which is how the three call sites that did so passed while comparing
180
- * 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.
181
183
  *
182
184
  * One copy of the layout, beside the encoder that produces it, so the two cannot
183
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
  *
@@ -60,7 +60,8 @@ export async function signWarrant(input) {
60
60
  const authorAccount = fromHex(input.authorAccount, 'authorAccount', 32);
61
61
  const executor = fromHex(input.executor, 'executor', 32);
62
62
  const executorKey = fromHex(input.executorKey, 'executorKey', 32);
63
- const appVersion = fromHex(input.appVersion ?? UNPINNED_APP, 'appVersion', 32);
63
+ const releaseBytecodeId = fromHex(input.releaseBytecodeId, 'releaseBytecodeId', 32);
64
+ const releaseVersion = releaseVersionBytes(input.releaseVersion ?? '');
64
65
  const accountHeads = citedHeads(input.accountHeads, 'accountHeads');
65
66
  const governanceFloor = citedHeads(input.governanceFloor, 'governanceFloor');
66
67
  const signer = await resolveSigner(input.deviceSecret, input.signer, 'deviceSecret');
@@ -80,7 +81,8 @@ export async function signWarrant(input) {
80
81
  publicKey,
81
82
  executor,
82
83
  executorKey,
83
- appVersion,
84
+ releaseBytecodeId,
85
+ releaseVersion,
84
86
  method,
85
87
  commitment,
86
88
  u64le(accountHeads.length),
@@ -93,7 +95,7 @@ export async function signWarrant(input) {
93
95
  const signature = checkedSignature(await signer.sign(preimage));
94
96
  // Borsh: a `String` is a u32 length then its UTF-8 bytes; a `Vec<[u8; 32]>` is
95
97
  // a u32 count then the elements. u32 HERE; u64 in the preimage above.
96
- return hex(concat(context, authorAccount, publicKey, executor, executorKey, 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));
97
99
  }
98
100
  /**
99
101
  * Decode a cited-head list, refusing one longer than a node will accept.
@@ -113,6 +115,17 @@ export function checkedSignature(signature) {
113
115
  }
114
116
  return signature;
115
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
+ }
116
129
  export function citedHeads(heads, label) {
117
130
  const list = heads ?? [];
118
131
  if (list.length > MAX_CITED_HEADS) {
@@ -124,11 +137,11 @@ export function citedHeads(heads, label) {
124
137
  * Decode a hex warrant back into its fields.
125
138
  *
126
139
  * Exists because v2's offsets are no longer constants: `method` is a string and
127
- * the head lists are vectors, so everything after `appVersion` moves with the
128
- * method's length. Anything reading a warrant by fixed offset — a test, a client
129
- * inspecting one it was handed — silently reads the wrong field instead of
130
- * failing, which is how the three call sites that did so passed while comparing
131
- * 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.
132
145
  *
133
146
  * One copy of the layout, beside the encoder that produces it, so the two cannot
134
147
  * drift. Internal: not exported from the package root, because a client that
@@ -149,7 +162,11 @@ export function parseWarrant(warrant) {
149
162
  const deviceKey = take(32);
150
163
  const executor = take(32);
151
164
  const executorKey = take(32);
152
- const appVersion = take(32);
165
+ const releaseBytecodeId = take(32);
166
+ const releaseVersionLen = u32At(o);
167
+ o += 4;
168
+ checkReleaseVersionLength(releaseVersionLen);
169
+ const releaseVersion = take(releaseVersionLen);
153
170
  const methodLen = u32At(o);
154
171
  o += 4;
155
172
  const method = take(methodLen);
@@ -172,7 +189,8 @@ export function parseWarrant(warrant) {
172
189
  deviceKey,
173
190
  executor,
174
191
  executorKey,
175
- appVersion,
192
+ releaseBytecodeId,
193
+ releaseVersion,
176
194
  method,
177
195
  intentHash: intentHashField,
178
196
  accountHeads,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calimero-network/mero-js",
3
- "version": "24.2.0",
3
+ "version": "24.4.0",
4
4
  "description": "Pure JavaScript SDK for Calimero",
5
5
  "type": "module",
6
6
  "exports": {