@occa/sdk 0.4.0 → 0.5.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.
@@ -140,6 +140,162 @@
140
140
  }
141
141
  ]
142
142
  },
143
+ {
144
+ "name": "commit_trace",
145
+ "docs": [
146
+ "Anchor a single completed, verified deliverable on-chain — per-task",
147
+ "provenance. Where `commit_daily_anchor` commits one Merkle root over",
148
+ "a day's task stream, this records ONE deliverable: a link to the",
149
+ "result, the content hash that locks it, and the verification verdict",
150
+ "+ quality score.",
151
+ "",
152
+ "Only deliverables that PASSED the off-chain verification gate are",
153
+ "anchored (policy: keep unverified / spam output off the chain).",
154
+ "`verdict` is therefore always written `TRACE_VERDICT_PASSED`; failed",
155
+ "work is simply never committed through this ix.",
156
+ "",
157
+ "Per Whitepaper §2 trace architecture: the deliverable itself stays",
158
+ "off-chain. `content_hash` makes the result tamper-evident and",
159
+ "`evidence_hash` locks the off-chain verification report. Anyone can",
160
+ "re-hash the artifact + report and verify against these. The on-chain",
161
+ "`quality_score` + `rubric_version` make the quality signal itself",
162
+ "tamper-evident — the agent cannot self-assert it.",
163
+ "",
164
+ "Seeds: `[\"trace\", task_id]`. PDA collision = at most one anchor per",
165
+ "task. Re-attempts on the same `task_id` fail naturally (Anchor `init`",
166
+ "rejects).",
167
+ "",
168
+ "Authorization: identical model to `commit_daily_anchor` — signed by",
169
+ "the Anchor Wallet registered as `OperationsAccount[Anchor]`; this",
170
+ "ix's discriminator must be whitelisted on that account. Resolved via",
171
+ "cross-program PDA lookup (`seeds::program = treasury::ID`).",
172
+ "",
173
+ "Phase 1 rate limit: NOT enforced (same constraint as",
174
+ "`commit_daily_anchor` — registry cannot mutate the treasury-owned",
175
+ "ops counter without an extra CPI). PDA-per-task dedup + rent burn",
176
+ "cap griefing."
177
+ ],
178
+ "discriminator": [
179
+ 58,
180
+ 140,
181
+ 230,
182
+ 51,
183
+ 170,
184
+ 109,
185
+ 228,
186
+ 125
187
+ ],
188
+ "accounts": [
189
+ {
190
+ "name": "deployment",
191
+ "docs": [
192
+ "Deployment that produced this deliverable."
193
+ ]
194
+ },
195
+ {
196
+ "name": "company",
197
+ "docs": [
198
+ "CompanyAccount referenced by deployment. Verified via",
199
+ "`deployment.company == company.key()` in the handler so we can also",
200
+ "resolve the OperationsAccount[Anchor] PDA from it."
201
+ ]
202
+ },
203
+ {
204
+ "name": "anchor_signer",
205
+ "docs": [
206
+ "Anchor Wallet — pubkey verified against `operations.signer`."
207
+ ],
208
+ "signer": true
209
+ },
210
+ {
211
+ "name": "operations",
212
+ "docs": [
213
+ "`OperationsAccount[Anchor]` from treasury program. Resolved via",
214
+ "cross-program PDA derivation; Anchor `Account<T>` auto-verifies",
215
+ "owner = treasury::ID."
216
+ ]
217
+ },
218
+ {
219
+ "name": "trace_anchor",
220
+ "writable": true,
221
+ "pda": {
222
+ "seeds": [
223
+ {
224
+ "kind": "const",
225
+ "value": [
226
+ 116,
227
+ 114,
228
+ 97,
229
+ 99,
230
+ 101
231
+ ]
232
+ },
233
+ {
234
+ "kind": "arg",
235
+ "path": "task_id"
236
+ }
237
+ ]
238
+ }
239
+ },
240
+ {
241
+ "name": "payer",
242
+ "docs": [
243
+ "Pays rent for the TraceAnchorAccount PDA."
244
+ ],
245
+ "writable": true,
246
+ "signer": true
247
+ },
248
+ {
249
+ "name": "system_program",
250
+ "address": "11111111111111111111111111111111"
251
+ }
252
+ ],
253
+ "args": [
254
+ {
255
+ "name": "task_id",
256
+ "type": {
257
+ "array": [
258
+ "u8",
259
+ 32
260
+ ]
261
+ }
262
+ },
263
+ {
264
+ "name": "result_uri",
265
+ "type": "string"
266
+ },
267
+ {
268
+ "name": "content_hash",
269
+ "type": {
270
+ "array": [
271
+ "u8",
272
+ 32
273
+ ]
274
+ }
275
+ },
276
+ {
277
+ "name": "quality_score",
278
+ "type": "u8"
279
+ },
280
+ {
281
+ "name": "rubric_version",
282
+ "type": "u8"
283
+ },
284
+ {
285
+ "name": "evidence_hash",
286
+ "type": {
287
+ "array": [
288
+ "u8",
289
+ 32
290
+ ]
291
+ }
292
+ },
293
+ {
294
+ "name": "completed_at",
295
+ "type": "i64"
296
+ }
297
+ ]
298
+ },
143
299
  {
144
300
  "name": "create_company",
145
301
  "docs": [
@@ -286,23 +442,29 @@
286
442
  ],
287
443
  "accounts": [
288
444
  {
289
- "name": "company"
445
+ "name": "company",
446
+ "docs": [
447
+ "Company the agent is being deployed into. Referenced only — the",
448
+ "company owner's consent to the hire is captured off-chain (the",
449
+ "invite), so the company owner is NOT a required signer here."
450
+ ]
290
451
  },
291
452
  {
292
453
  "name": "identity",
293
454
  "docs": [
294
- "AgentIdentity to deploy. Phase 1: must be owned by the same",
295
- "wallet as the company (enforced in handler)."
455
+ "AgentIdentity being deployed. The IDENTITY owner signs deploying",
456
+ "an agent (incl. into another owner's company, the marketplace case)",
457
+ "is authorized by the agent's owner accepting."
296
458
  ]
297
459
  },
298
460
  {
299
461
  "name": "owner",
300
462
  "docs": [
301
- "Company owner (signer). Authority for state changes."
463
+ "Agent owner (signer) authorizes deploying THEIR agent."
302
464
  ],
303
465
  "signer": true,
304
466
  "relations": [
305
- "company"
467
+ "identity"
306
468
  ]
307
469
  },
308
470
  {
@@ -508,6 +670,44 @@
508
670
  ],
509
671
  "args": []
510
672
  },
673
+ {
674
+ "name": "set_agent_receiving_address",
675
+ "docs": [
676
+ "Set or replace the agent's PERSONAL receiving wallet — the passive",
677
+ "destination for funds disbursed to this agent, intrinsic to the",
678
+ "identity (no company/deployment required). Pass `Pubkey::default()`",
679
+ "to clear. Identity-owner only. NEVER a signer — it only receives."
680
+ ],
681
+ "discriminator": [
682
+ 197,
683
+ 126,
684
+ 122,
685
+ 18,
686
+ 38,
687
+ 62,
688
+ 179,
689
+ 218
690
+ ],
691
+ "accounts": [
692
+ {
693
+ "name": "identity",
694
+ "writable": true
695
+ },
696
+ {
697
+ "name": "owner",
698
+ "signer": true,
699
+ "relations": [
700
+ "identity"
701
+ ]
702
+ }
703
+ ],
704
+ "args": [
705
+ {
706
+ "name": "new_receiving_address",
707
+ "type": "pubkey"
708
+ }
709
+ ]
710
+ },
511
711
  {
512
712
  "name": "set_receiving_address",
513
713
  "docs": [
@@ -822,6 +1022,19 @@
822
1022
  64,
823
1023
  178
824
1024
  ]
1025
+ },
1026
+ {
1027
+ "name": "TraceAnchorAccount",
1028
+ "discriminator": [
1029
+ 159,
1030
+ 101,
1031
+ 186,
1032
+ 98,
1033
+ 211,
1034
+ 217,
1035
+ 119,
1036
+ 232
1037
+ ]
825
1038
  }
826
1039
  ],
827
1040
  "errors": [
@@ -924,6 +1137,21 @@
924
1137
  "code": 6019,
925
1138
  "name": "InvalidDiscriminator",
926
1139
  "msg": "could not parse instruction discriminator"
1140
+ },
1141
+ {
1142
+ "code": 6020,
1143
+ "name": "ResultUriTooLong",
1144
+ "msg": "result_uri exceeds MAX_RESULT_URI_LEN"
1145
+ },
1146
+ {
1147
+ "code": 6021,
1148
+ "name": "InvalidQualityScore",
1149
+ "msg": "quality_score exceeds MAX_QUALITY_SCORE (0-100)"
1150
+ },
1151
+ {
1152
+ "code": 6022,
1153
+ "name": "InvalidCompletedAt",
1154
+ "msg": "completed_at must be > 0 and not in the future"
927
1155
  }
928
1156
  ],
929
1157
  "types": [
@@ -953,6 +1181,18 @@
953
1181
  ],
954
1182
  "type": "pubkey"
955
1183
  },
1184
+ {
1185
+ "name": "receiving_address",
1186
+ "docs": [
1187
+ "The agent's PERSONAL receiving wallet — a passive destination for",
1188
+ "funds disbursed to this agent, intrinsic to the identity (like a",
1189
+ "person's bank account). Owner-set anytime via",
1190
+ "`set_agent_receiving_address`, independent of any company/deployment.",
1191
+ "At `create_deployment` the deployment's `receiving_address` defaults",
1192
+ "to this. `Pubkey::default()` = unset. NEVER a signer."
1193
+ ],
1194
+ "type": "pubkey"
1195
+ },
956
1196
  {
957
1197
  "name": "created_at",
958
1198
  "docs": [
@@ -1410,6 +1650,150 @@
1410
1650
  }
1411
1651
  ]
1412
1652
  }
1653
+ },
1654
+ {
1655
+ "name": "TraceAnchorAccount",
1656
+ "docs": [
1657
+ "Per-deliverable provenance record. One per completed, verified task.",
1658
+ "The deliverable content lives off-chain; this account locks its",
1659
+ "identity, content hash, and verified quality so it is tamper-evident",
1660
+ "and attributable. Reputation views are derived off-chain by folding",
1661
+ "over these accounts per `agent`."
1662
+ ],
1663
+ "type": {
1664
+ "kind": "struct",
1665
+ "fields": [
1666
+ {
1667
+ "name": "version",
1668
+ "docs": [
1669
+ "Schema version."
1670
+ ],
1671
+ "type": "u8"
1672
+ },
1673
+ {
1674
+ "name": "task_id",
1675
+ "docs": [
1676
+ "Hash of task creation params — globally unique task id, also baked",
1677
+ "into the PDA seed."
1678
+ ],
1679
+ "type": {
1680
+ "array": [
1681
+ "u8",
1682
+ 32
1683
+ ]
1684
+ }
1685
+ },
1686
+ {
1687
+ "name": "company",
1688
+ "docs": [
1689
+ "CompanyAccount the work was produced in (denormalized for indexing)."
1690
+ ],
1691
+ "type": "pubkey"
1692
+ },
1693
+ {
1694
+ "name": "agent",
1695
+ "docs": [
1696
+ "AgentIdentity PDA that produced the deliverable. Reputation",
1697
+ "aggregates against this (stable across redeployments), NOT the",
1698
+ "per-company deployment."
1699
+ ],
1700
+ "type": "pubkey"
1701
+ },
1702
+ {
1703
+ "name": "deployment",
1704
+ "docs": [
1705
+ "Deployment PDA active when the work was produced (company context)."
1706
+ ],
1707
+ "type": "pubkey"
1708
+ },
1709
+ {
1710
+ "name": "result_uri",
1711
+ "docs": [
1712
+ "Link to the deliverable (article URL, PR, etc). Off-chain pointer."
1713
+ ],
1714
+ "type": "string"
1715
+ },
1716
+ {
1717
+ "name": "content_hash",
1718
+ "docs": [
1719
+ "SHA-256 of the deliverable content at completion — tamper-evidence",
1720
+ "for the result behind `result_uri`."
1721
+ ],
1722
+ "type": {
1723
+ "array": [
1724
+ "u8",
1725
+ 32
1726
+ ]
1727
+ }
1728
+ },
1729
+ {
1730
+ "name": "verdict",
1731
+ "docs": [
1732
+ "Verification verdict. Always `TRACE_VERDICT_PASSED` — only passed",
1733
+ "deliverables are anchored."
1734
+ ],
1735
+ "type": "u8"
1736
+ },
1737
+ {
1738
+ "name": "quality_score",
1739
+ "docs": [
1740
+ "Rubric quality score 0-100 (see `MAX_QUALITY_SCORE`). Tamper-evident",
1741
+ "quality signal, produced by the deterministic gate, not the agent."
1742
+ ],
1743
+ "type": "u8"
1744
+ },
1745
+ {
1746
+ "name": "rubric_version",
1747
+ "docs": [
1748
+ "Version of the scoring rubric that produced `quality_score`. Scores",
1749
+ "are only comparable within the same rubric version."
1750
+ ],
1751
+ "type": "u8"
1752
+ },
1753
+ {
1754
+ "name": "evidence_hash",
1755
+ "docs": [
1756
+ "SHA-256 of the off-chain verification report (claims checked +",
1757
+ "sources). Lets anyone audit WHY the verdict/score was assigned."
1758
+ ],
1759
+ "type": {
1760
+ "array": [
1761
+ "u8",
1762
+ 32
1763
+ ]
1764
+ }
1765
+ },
1766
+ {
1767
+ "name": "completed_at",
1768
+ "docs": [
1769
+ "Unix timestamp the deliverable was completed off-chain."
1770
+ ],
1771
+ "type": "i64"
1772
+ },
1773
+ {
1774
+ "name": "committed_at",
1775
+ "docs": [
1776
+ "Unix timestamp this commit landed on-chain."
1777
+ ],
1778
+ "type": "i64"
1779
+ },
1780
+ {
1781
+ "name": "committed_by",
1782
+ "docs": [
1783
+ "Anchor Wallet pubkey that signed the commit. Mirror of",
1784
+ "`OperationsAccount[Anchor].signer` at commit time."
1785
+ ],
1786
+ "type": "pubkey"
1787
+ },
1788
+ {
1789
+ "name": "bump",
1790
+ "docs": [
1791
+ "Bump for PDA verification."
1792
+ ],
1793
+ "type": "u8"
1794
+ }
1795
+ ]
1796
+ }
1413
1797
  }
1414
1798
  ]
1415
1799
  }
@@ -4,6 +4,8 @@ import {
4
4
  TransactionInstruction,
5
5
  } from "@solana/web3.js";
6
6
  import {
7
+ MAX_QUALITY_SCORE,
8
+ MAX_RESULT_URI_LEN,
7
9
  OPERATIONS_KIND,
8
10
  REGISTRY_PROGRAM_ID,
9
11
  SOL_PSEUDO_MINT,
@@ -18,6 +20,7 @@ import {
18
20
  deriveOperationsPda,
19
21
  derivePolicyPda,
20
22
  deriveProtocolFeePda,
23
+ deriveTraceAnchorPda,
21
24
  deriveTreasuryPda,
22
25
  u32LeBytes,
23
26
  } from "./pda";
@@ -42,7 +45,9 @@ export const INSTRUCTION_DISCRIMINATOR = {
42
45
  updateDeploymentStatus: Buffer.from([225, 195, 150, 254, 178, 203, 53, 147]),
43
46
  retireDeployment: Buffer.from([45, 188, 162, 197, 136, 180, 202, 153]),
44
47
  setReceivingAddress: Buffer.from([70, 63, 44, 87, 16, 6, 156, 200]),
48
+ setAgentReceivingAddress: Buffer.from([197, 126, 122, 18, 38, 62, 179, 218]),
45
49
  commitDailyAnchor: Buffer.from([18, 7, 3, 65, 58, 148, 164, 0]),
50
+ commitTrace: Buffer.from([58, 140, 230, 51, 170, 109, 228, 125]),
46
51
  } as const;
47
52
 
48
53
  // ── Borsh primitives ────────────────────────────────────────────────────────
@@ -461,6 +466,40 @@ export function buildSetReceivingAddressInstruction(
461
466
  return { instruction };
462
467
  }
463
468
 
469
+ export interface SetAgentReceivingAddressParams {
470
+ /** AgentIdentity PDA whose personal receiving wallet is being set. */
471
+ identityPda: PublicKey;
472
+ /** User wallet — must equal `identity.owner`. */
473
+ owner: PublicKey;
474
+ /** New personal receiving address (passive destination for funds
475
+ * disbursed to this agent). Pass `PublicKey.default` to clear. NEVER a
476
+ * signer — it never authorizes any on-chain action. */
477
+ newReceivingAddress: PublicKey;
478
+ programId?: PublicKey;
479
+ }
480
+
481
+ // Sets the agent's PERSONAL receiving wallet on the AgentIdentity itself —
482
+ // independent of any company/deployment (Phase 4 marketplace). The deployment
483
+ // receiving_address defaults from this at create_deployment.
484
+ export function buildSetAgentReceivingAddressInstruction(
485
+ params: SetAgentReceivingAddressParams,
486
+ ): { instruction: TransactionInstruction } {
487
+ const programId = params.programId ?? REGISTRY_PROGRAM_ID;
488
+ const data = Buffer.concat([
489
+ INSTRUCTION_DISCRIMINATOR.setAgentReceivingAddress,
490
+ encodePubkey(params.newReceivingAddress),
491
+ ]);
492
+ const instruction = new TransactionInstruction({
493
+ programId,
494
+ keys: [
495
+ { pubkey: params.identityPda, isSigner: false, isWritable: true },
496
+ { pubkey: params.owner, isSigner: true, isWritable: false },
497
+ ],
498
+ data,
499
+ });
500
+ return { instruction };
501
+ }
502
+
464
503
  // ── Treasury program ────────────────────────────────────────────────────────
465
504
  //
466
505
  // The treasury program is a SEPARATE program from registry — these
@@ -1169,3 +1208,113 @@ export function buildCommitDailyAnchorInstruction(
1169
1208
  });
1170
1209
  return { instruction };
1171
1210
  }
1211
+
1212
+ export interface CommitTraceParams {
1213
+ deploymentPda: PublicKey;
1214
+ companyPda: PublicKey;
1215
+ /** Anchor Wallet — must equal `operations.signer` (kind=Anchor). */
1216
+ anchorSigner: PublicKey;
1217
+ /** Anchor-kind OperationsAccount for this company (in TREASURY program,
1218
+ * read-only here). Caller derives via `deriveOperationsPda(company,
1219
+ * OPERATIONS_KIND.Anchor)`. */
1220
+ operationsPda: PublicKey;
1221
+ /** 32-byte hash of the task creation params — globally unique, becomes
1222
+ * part of the TraceAnchor PDA seed. */
1223
+ taskId: Uint8Array | Buffer;
1224
+ /** Link to the deliverable (article URL, PR, etc). Max
1225
+ * `MAX_RESULT_URI_LEN` bytes. */
1226
+ resultUri: string;
1227
+ /** SHA-256 of the deliverable content at completion (32 bytes). */
1228
+ contentHash: Uint8Array | Buffer;
1229
+ /** Rubric quality score, 0..=`MAX_QUALITY_SCORE`. */
1230
+ qualityScore: number;
1231
+ /** Version of the scoring rubric that produced `qualityScore`. */
1232
+ rubricVersion: number;
1233
+ /** SHA-256 of the off-chain verification report (32 bytes). */
1234
+ evidenceHash: Uint8Array | Buffer;
1235
+ /** Unix seconds the deliverable was completed off-chain. Must be > 0 and
1236
+ * not in the future. */
1237
+ completedAt: bigint;
1238
+ /** Rent payer (typically the operator hot wallet). */
1239
+ payer: PublicKey;
1240
+ programId?: PublicKey;
1241
+ }
1242
+
1243
+ /**
1244
+ * Build a `commit_trace` instruction — Anchor-class single-tx commit of one
1245
+ * completed, VERIFIED deliverable. Signed by the Anchor Wallet bound to the
1246
+ * company's Anchor-kind OperationsAccount (same authority as
1247
+ * `commit_daily_anchor`).
1248
+ *
1249
+ * Only deliverables that passed the off-chain verification gate should be
1250
+ * committed — the on-chain `verdict` is always Passed. Caller is
1251
+ * responsible for:
1252
+ * 1. Computing `contentHash` (SHA-256 of the deliverable)
1253
+ * 2. Running the verification gate + scoring rubric off-chain
1254
+ * 3. Computing `evidenceHash` (SHA-256 of the verification report)
1255
+ *
1256
+ * The verdict byte is fixed on-chain; it is not a caller arg.
1257
+ */
1258
+ export function buildCommitTraceInstruction(params: CommitTraceParams): {
1259
+ instruction: TransactionInstruction;
1260
+ traceAnchorPda: PublicKey;
1261
+ } {
1262
+ const programId = params.programId ?? REGISTRY_PROGRAM_ID;
1263
+ if (params.taskId.length !== 32) {
1264
+ throw new RangeError(`taskId must be 32 bytes, got ${params.taskId.length}`);
1265
+ }
1266
+ if (params.contentHash.length !== 32) {
1267
+ throw new RangeError(
1268
+ `contentHash must be 32 bytes, got ${params.contentHash.length}`,
1269
+ );
1270
+ }
1271
+ if (params.evidenceHash.length !== 32) {
1272
+ throw new RangeError(
1273
+ `evidenceHash must be 32 bytes, got ${params.evidenceHash.length}`,
1274
+ );
1275
+ }
1276
+ if (Buffer.from(params.resultUri, "utf8").length > MAX_RESULT_URI_LEN) {
1277
+ throw new RangeError(
1278
+ `resultUri exceeds MAX_RESULT_URI_LEN (${MAX_RESULT_URI_LEN} bytes)`,
1279
+ );
1280
+ }
1281
+ if (params.qualityScore < 0 || params.qualityScore > MAX_QUALITY_SCORE) {
1282
+ throw new RangeError(
1283
+ `qualityScore out of range 0..=${MAX_QUALITY_SCORE}: ${params.qualityScore}`,
1284
+ );
1285
+ }
1286
+
1287
+ const taskId = Buffer.from(params.taskId);
1288
+ const { pda: traceAnchorPda } = deriveTraceAnchorPda(taskId, programId);
1289
+
1290
+ // Wire arg order must match the Rust handler: task_id, result_uri,
1291
+ // content_hash, quality_score, rubric_version, evidence_hash,
1292
+ // completed_at. verdict is NOT a wire arg (fixed Passed on-chain).
1293
+ const data = Buffer.concat([
1294
+ INSTRUCTION_DISCRIMINATOR.commitTrace,
1295
+ taskId,
1296
+ encodeString(params.resultUri),
1297
+ Buffer.from(params.contentHash),
1298
+ encodeU8(params.qualityScore),
1299
+ encodeU8(params.rubricVersion),
1300
+ Buffer.from(params.evidenceHash),
1301
+ encodeI64(params.completedAt),
1302
+ ]);
1303
+
1304
+ const instruction = new TransactionInstruction({
1305
+ programId,
1306
+ // Order: deployment, company, anchor_signer, operations, trace_anchor,
1307
+ // payer, system_program.
1308
+ keys: [
1309
+ { pubkey: params.deploymentPda, isSigner: false, isWritable: false },
1310
+ { pubkey: params.companyPda, isSigner: false, isWritable: false },
1311
+ { pubkey: params.anchorSigner, isSigner: true, isWritable: false },
1312
+ { pubkey: params.operationsPda, isSigner: false, isWritable: false },
1313
+ { pubkey: traceAnchorPda, isSigner: false, isWritable: true },
1314
+ { pubkey: params.payer, isSigner: true, isWritable: true },
1315
+ { pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
1316
+ ],
1317
+ data,
1318
+ });
1319
+ return { instruction, traceAnchorPda };
1320
+ }
package/src/pda.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  POLICY_SEED,
9
9
  PROTOCOL_FEES_SEED,
10
10
  REGISTRY_PROGRAM_ID,
11
+ TRACE_SEED,
11
12
  TREASURY_PROGRAM_ID,
12
13
  TREASURY_SEED,
13
14
  type OperationsKind,
@@ -197,3 +198,27 @@ export function deriveDailyAnchorPda(
197
198
  );
198
199
  return { pda, bump };
199
200
  }
201
+
202
+ /**
203
+ * TraceAnchorAccount PDA — owned by Registry program. One per completed,
204
+ * verified deliverable (article, PR, etc).
205
+ *
206
+ * seeds = ["trace", task_id_32bytes]
207
+ *
208
+ * `taskId` is a 32-byte hash of the task creation params. Collision on the
209
+ * same `taskId` means the task is already anchored — a re-commit fails
210
+ * naturally (Anchor `init` rejects).
211
+ */
212
+ export function deriveTraceAnchorPda(
213
+ taskId: Uint8Array,
214
+ programId: PublicKey = REGISTRY_PROGRAM_ID,
215
+ ): { pda: PublicKey; bump: number } {
216
+ if (taskId.length !== 32) {
217
+ throw new RangeError(`taskId must be 32 bytes, got ${taskId.length}`);
218
+ }
219
+ const [pda, bump] = PublicKey.findProgramAddressSync(
220
+ [TRACE_SEED, Buffer.from(taskId)],
221
+ programId,
222
+ );
223
+ return { pda, bump };
224
+ }