@agirails/sdk 2.2.3 → 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.
Files changed (93) hide show
  1. package/README.md +20 -23
  2. package/dist/ACTPClient.d.ts +7 -0
  3. package/dist/ACTPClient.d.ts.map +1 -1
  4. package/dist/ACTPClient.js +56 -1
  5. package/dist/ACTPClient.js.map +1 -1
  6. package/dist/abi/AgentRegistry.json +133 -0
  7. package/dist/adapters/X402Adapter.d.ts +34 -7
  8. package/dist/adapters/X402Adapter.d.ts.map +1 -1
  9. package/dist/adapters/X402Adapter.js +36 -8
  10. package/dist/adapters/X402Adapter.js.map +1 -1
  11. package/dist/adapters/index.d.ts +1 -1
  12. package/dist/adapters/index.d.ts.map +1 -1
  13. package/dist/adapters/index.js.map +1 -1
  14. package/dist/cli/commands/diff.d.ts +11 -0
  15. package/dist/cli/commands/diff.d.ts.map +1 -0
  16. package/dist/cli/commands/diff.js +115 -0
  17. package/dist/cli/commands/diff.js.map +1 -0
  18. package/dist/cli/commands/init.d.ts.map +1 -1
  19. package/dist/cli/commands/init.js +51 -2
  20. package/dist/cli/commands/init.js.map +1 -1
  21. package/dist/cli/commands/publish.d.ts +11 -0
  22. package/dist/cli/commands/publish.d.ts.map +1 -0
  23. package/dist/cli/commands/publish.js +170 -0
  24. package/dist/cli/commands/publish.js.map +1 -0
  25. package/dist/cli/commands/pull.d.ts +12 -0
  26. package/dist/cli/commands/pull.d.ts.map +1 -0
  27. package/dist/cli/commands/pull.js +99 -0
  28. package/dist/cli/commands/pull.js.map +1 -0
  29. package/dist/cli/index.js +7 -0
  30. package/dist/cli/index.js.map +1 -1
  31. package/dist/config/agirailsmd.d.ts +94 -0
  32. package/dist/config/agirailsmd.d.ts.map +1 -0
  33. package/dist/config/agirailsmd.js +209 -0
  34. package/dist/config/agirailsmd.js.map +1 -0
  35. package/dist/config/networks.d.ts +2 -0
  36. package/dist/config/networks.d.ts.map +1 -1
  37. package/dist/config/networks.js +10 -4
  38. package/dist/config/networks.js.map +1 -1
  39. package/dist/config/publishPipeline.d.ts +61 -0
  40. package/dist/config/publishPipeline.d.ts.map +1 -0
  41. package/dist/config/publishPipeline.js +192 -0
  42. package/dist/config/publishPipeline.js.map +1 -0
  43. package/dist/config/syncOperations.d.ts +67 -0
  44. package/dist/config/syncOperations.d.ts.map +1 -0
  45. package/dist/config/syncOperations.js +208 -0
  46. package/dist/config/syncOperations.js.map +1 -0
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +7 -3
  50. package/dist/index.js.map +1 -1
  51. package/dist/level0/request.d.ts.map +1 -1
  52. package/dist/level0/request.js +23 -86
  53. package/dist/level0/request.js.map +1 -1
  54. package/dist/level1/Agent.d.ts +0 -11
  55. package/dist/level1/Agent.d.ts.map +1 -1
  56. package/dist/level1/Agent.js +15 -32
  57. package/dist/level1/Agent.js.map +1 -1
  58. package/dist/registry/AgentRegistryClient.d.ts +75 -0
  59. package/dist/registry/AgentRegistryClient.d.ts.map +1 -0
  60. package/dist/registry/AgentRegistryClient.js +160 -0
  61. package/dist/registry/AgentRegistryClient.js.map +1 -0
  62. package/dist/types/adapter.d.ts +39 -0
  63. package/dist/types/adapter.d.ts.map +1 -1
  64. package/dist/types/adapter.js +7 -0
  65. package/dist/types/adapter.js.map +1 -1
  66. package/dist/types/x402.d.ts +23 -0
  67. package/dist/types/x402.d.ts.map +1 -1
  68. package/dist/types/x402.js.map +1 -1
  69. package/dist/wallet/keystore.d.ts +16 -0
  70. package/dist/wallet/keystore.d.ts.map +1 -0
  71. package/dist/wallet/keystore.js +132 -0
  72. package/dist/wallet/keystore.js.map +1 -0
  73. package/package.json +2 -1
  74. package/src/ACTPClient.ts +63 -1
  75. package/src/abi/AgentRegistry.json +133 -0
  76. package/src/adapters/X402Adapter.ts +94 -16
  77. package/src/adapters/index.ts +9 -1
  78. package/src/cli/commands/diff.ts +141 -0
  79. package/src/cli/commands/init.ts +65 -4
  80. package/src/cli/commands/publish.ts +209 -0
  81. package/src/cli/commands/pull.ts +124 -0
  82. package/src/cli/index.ts +8 -0
  83. package/src/config/agirailsmd.ts +262 -0
  84. package/src/config/networks.ts +12 -4
  85. package/src/config/publishPipeline.ts +276 -0
  86. package/src/config/syncOperations.ts +279 -0
  87. package/src/index.ts +3 -0
  88. package/src/level0/request.ts +27 -88
  89. package/src/level1/Agent.ts +16 -32
  90. package/src/registry/AgentRegistryClient.ts +202 -0
  91. package/src/types/adapter.ts +14 -0
  92. package/src/types/x402.ts +32 -0
  93. package/src/wallet/keystore.ts +119 -0
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Publish Pipeline - AGIRAILS.md → IPFS → Arweave → On-Chain
3
+ *
4
+ * Orchestrates the full publish flow:
5
+ * 1. Read AGIRAILS.md → parse → compute configHash
6
+ * 2. Upload to Filebase (IPFS pinning)
7
+ * 3. Upload to Arweave (permanent storage) [optional]
8
+ * 4. Call AgentRegistry.publishConfig(cid, hash) on-chain
9
+ * 5. Update AGIRAILS.md frontmatter with config_hash and published_at
10
+ *
11
+ * @module config/publishPipeline
12
+ */
13
+
14
+ import { readFileSync, writeFileSync } from 'fs';
15
+ import { Signer, keccak256, toUtf8Bytes } from 'ethers';
16
+ import { parseAgirailsMd, computeConfigHash, computeConfigHashFromParts, serializeAgirailsMd } from './agirailsmd';
17
+ import { AgentRegistryClient } from '../registry/AgentRegistryClient';
18
+ import { AgentRegistry } from '../protocol/AgentRegistry';
19
+ import { FilebaseClient } from '../storage/FilebaseClient';
20
+ import { ArweaveClient } from '../storage/ArweaveClient';
21
+ import { ServiceDescriptor } from '../types';
22
+
23
+ // ============================================================================
24
+ // Types
25
+ // ============================================================================
26
+
27
+ export interface PublishOptions {
28
+ /** Path to AGIRAILS.md file */
29
+ path: string;
30
+ /** Network name (for registry address lookup) */
31
+ network: string;
32
+ /** AgentRegistry contract address */
33
+ registryAddress: string;
34
+ /** Signer for on-chain transactions */
35
+ signer: Signer;
36
+ /** Filebase client for IPFS upload */
37
+ filebaseClient: FilebaseClient;
38
+ /** Arweave client for permanent storage (optional) */
39
+ arweaveClient?: ArweaveClient;
40
+ /** Skip Arweave upload (dev mode) */
41
+ skipArweave?: boolean;
42
+ /** Dry run - compute and show but don't execute */
43
+ dryRun?: boolean;
44
+ /** Gas settings */
45
+ gasSettings?: {
46
+ maxFeePerGas?: bigint;
47
+ maxPriorityFeePerGas?: bigint;
48
+ };
49
+ }
50
+
51
+ export interface PublishResult {
52
+ /** IPFS CID of the uploaded AGIRAILS.md */
53
+ cid: string;
54
+ /** Canonical config hash (bytes32) */
55
+ configHash: string;
56
+ /** On-chain transaction hash */
57
+ txHash?: string;
58
+ /** Arweave transaction ID (if uploaded) */
59
+ arweaveTxId?: string;
60
+ /** Whether this was a dry run */
61
+ dryRun: boolean;
62
+ /** Whether the agent was auto-registered during this publish */
63
+ registered?: boolean;
64
+ }
65
+
66
+ // ============================================================================
67
+ // Registration Helpers
68
+ // ============================================================================
69
+
70
+ export const PENDING_ENDPOINT = 'https://pending.agirails.io';
71
+
72
+ /** Default values for capabilities-to-services conversion */
73
+ const SERVICE_DEFAULTS = {
74
+ schemaURI: '',
75
+ minPrice: 0n,
76
+ maxPrice: 1_000_000_000n, // 1000 USDC
77
+ avgCompletionTime: 3600, // 1 hour
78
+ metadataCID: '',
79
+ };
80
+
81
+ /** Max safe USDC value before BigInt conversion loses precision */
82
+ const MAX_SAFE_USDC = Math.floor(Number.MAX_SAFE_INTEGER / 1_000_000);
83
+
84
+ /** Validate service type format (must match contract requirements) */
85
+ function validateServiceType(serviceType: string, source: string): void {
86
+ if (!serviceType) {
87
+ throw new Error(`Empty service type in ${source}`);
88
+ }
89
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(serviceType)) {
90
+ throw new Error(
91
+ `Invalid service type "${serviceType}" in ${source}. ` +
92
+ 'Must be lowercase alphanumeric with hyphens (e.g., "text-generation").'
93
+ );
94
+ }
95
+ }
96
+
97
+ /** Convert human-readable USDC to 6-decimal base units with overflow check */
98
+ function usdcToBaseUnits(value: number, fieldName: string): bigint {
99
+ if (value < 0) throw new Error(`${fieldName} cannot be negative`);
100
+ if (value > MAX_SAFE_USDC) throw new Error(`${fieldName} exceeds maximum safe value (${MAX_SAFE_USDC} USDC)`);
101
+ return BigInt(Math.round(value * 1_000_000));
102
+ }
103
+
104
+ /**
105
+ * Extract registration params from AGIRAILS.md frontmatter.
106
+ *
107
+ * Supports two formats:
108
+ * - `services`: full ServiceDescriptor objects with pricing
109
+ * - `capabilities`: simple string list, auto-converted with defaults
110
+ *
111
+ * @throws Error if neither services nor capabilities are present
112
+ */
113
+ function extractRegistrationParams(
114
+ frontmatter: Record<string, unknown>
115
+ ): { endpoint: string; serviceDescriptors: ServiceDescriptor[] } {
116
+ // Endpoint: use frontmatter field or placeholder
117
+ const endpoint = typeof frontmatter.endpoint === 'string' && frontmatter.endpoint
118
+ ? frontmatter.endpoint
119
+ : PENDING_ENDPOINT;
120
+
121
+ // Try explicit services first
122
+ if (Array.isArray(frontmatter.services) && frontmatter.services.length > 0) {
123
+ const serviceDescriptors = (frontmatter.services as Record<string, unknown>[]).map(svc => {
124
+ const serviceType = String(svc.type || svc.service_type || '').trim().toLowerCase();
125
+ validateServiceType(serviceType, 'services');
126
+
127
+ // Parse price range: "1.0-100.0" or separate min/max
128
+ let minPrice = SERVICE_DEFAULTS.minPrice;
129
+ let maxPrice = SERVICE_DEFAULTS.maxPrice;
130
+ if (typeof svc.price === 'string' && svc.price.includes('-')) {
131
+ const [min, max] = svc.price.split('-').map(Number);
132
+ minPrice = usdcToBaseUnits(min, 'min_price');
133
+ maxPrice = usdcToBaseUnits(max, 'max_price');
134
+ } else {
135
+ if (svc.min_price !== undefined) minPrice = usdcToBaseUnits(Number(svc.min_price), 'min_price');
136
+ if (svc.max_price !== undefined) maxPrice = usdcToBaseUnits(Number(svc.max_price), 'max_price');
137
+ }
138
+
139
+ return {
140
+ serviceTypeHash: keccak256(toUtf8Bytes(serviceType)),
141
+ serviceType,
142
+ schemaURI: String(svc.schema_uri || svc.schemaURI || SERVICE_DEFAULTS.schemaURI),
143
+ minPrice,
144
+ maxPrice,
145
+ avgCompletionTime: Number(svc.avg_completion_time || svc.avgCompletionTime || SERVICE_DEFAULTS.avgCompletionTime),
146
+ metadataCID: String(svc.metadata_cid || svc.metadataCID || SERVICE_DEFAULTS.metadataCID),
147
+ };
148
+ });
149
+ return { endpoint, serviceDescriptors };
150
+ }
151
+
152
+ // Fallback: convert capabilities list to services with defaults
153
+ if (Array.isArray(frontmatter.capabilities) && frontmatter.capabilities.length > 0) {
154
+ const serviceDescriptors = (frontmatter.capabilities as string[]).map(cap => {
155
+ const serviceType = String(cap).trim().toLowerCase();
156
+ validateServiceType(serviceType, 'capabilities');
157
+ return {
158
+ serviceTypeHash: keccak256(toUtf8Bytes(serviceType)),
159
+ serviceType,
160
+ schemaURI: SERVICE_DEFAULTS.schemaURI,
161
+ minPrice: SERVICE_DEFAULTS.minPrice,
162
+ maxPrice: SERVICE_DEFAULTS.maxPrice,
163
+ avgCompletionTime: SERVICE_DEFAULTS.avgCompletionTime,
164
+ metadataCID: SERVICE_DEFAULTS.metadataCID,
165
+ };
166
+ });
167
+ return { endpoint, serviceDescriptors };
168
+ }
169
+
170
+ throw new Error(
171
+ 'AGIRAILS.md must have "services" or "capabilities" in frontmatter for agent registration.\n' +
172
+ 'Add at least one, e.g.:\n' +
173
+ ' capabilities:\n' +
174
+ ' - text-generation\n'
175
+ );
176
+ }
177
+
178
+ // ============================================================================
179
+ // Pipeline
180
+ // ============================================================================
181
+
182
+ /**
183
+ * Execute the full publish pipeline.
184
+ *
185
+ * @param options - Publish configuration
186
+ * @returns Publish result with CID, hash, and transaction hashes
187
+ */
188
+ export async function publishAgirailsMd(options: PublishOptions): Promise<PublishResult> {
189
+ const {
190
+ path,
191
+ registryAddress,
192
+ signer,
193
+ filebaseClient,
194
+ arweaveClient,
195
+ skipArweave = false,
196
+ dryRun = false,
197
+ gasSettings,
198
+ } = options;
199
+
200
+ // Step 1: Read and parse
201
+ const content = readFileSync(path, 'utf-8');
202
+ const { frontmatter, body } = parseAgirailsMd(content);
203
+ const { configHash } = computeConfigHash(content);
204
+
205
+ if (dryRun) {
206
+ return {
207
+ cid: '(dry-run)',
208
+ configHash,
209
+ dryRun: true,
210
+ registered: false,
211
+ };
212
+ }
213
+
214
+ // Step 2: Upload raw AGIRAILS.md to IPFS via Filebase
215
+ // Upload the actual markdown file (not a JSON wrapper) so CID points to the real file
216
+ const ipfsResult = await filebaseClient.uploadBinary(
217
+ Buffer.from(content, 'utf-8'),
218
+ 'text/markdown',
219
+ { metadata: { type: 'agirails-config', version: '1.0' } }
220
+ );
221
+ const cid = ipfsResult.cid;
222
+
223
+ // Step 3: Upload to Arweave (optional)
224
+ // Arweave stores the JSON-structured form for archival querying.
225
+ // uploadJSON already sets Content-Type: application/json and Protocol: AGIRAILS as defaults.
226
+ let arweaveTxId: string | undefined;
227
+ if (!skipArweave && arweaveClient) {
228
+ const arweaveResult = await arweaveClient.uploadJSON(
229
+ { frontmatter, body, _format: 'agirails.md.v1' },
230
+ [
231
+ { name: 'Type', value: 'agent-config' },
232
+ { name: 'ConfigHash', value: configHash },
233
+ { name: 'IPFS-CID', value: cid },
234
+ ]
235
+ );
236
+ arweaveTxId = arweaveResult.txId;
237
+ }
238
+
239
+ // Step 4: Auto-register if needed, then publish on-chain
240
+ const registry = new AgentRegistry(registryAddress, signer, gasSettings);
241
+ const registryClient = new AgentRegistryClient(registryAddress, signer, gasSettings);
242
+ let registered = false;
243
+
244
+ const signerAddress = await signer.getAddress();
245
+ const profile = await registry.getAgent(signerAddress);
246
+
247
+ if (!profile) {
248
+ // Not registered — extract params from frontmatter and auto-register
249
+ const regParams = extractRegistrationParams(frontmatter);
250
+ await registry.registerAgent(regParams);
251
+ registered = true;
252
+ }
253
+
254
+ const { txHash } = await registryClient.publishConfig(cid, configHash);
255
+
256
+ // Step 5: Update frontmatter with publish metadata
257
+ const updatedFrontmatter = {
258
+ ...frontmatter,
259
+ config_hash: configHash,
260
+ published_at: new Date().toISOString(),
261
+ config_cid: cid,
262
+ ...(arweaveTxId ? { arweave_tx: arweaveTxId } : {}),
263
+ };
264
+
265
+ const updatedContent = serializeAgirailsMd(updatedFrontmatter, body);
266
+ writeFileSync(path, updatedContent, 'utf-8');
267
+
268
+ return {
269
+ cid,
270
+ configHash,
271
+ txHash,
272
+ arweaveTxId,
273
+ dryRun: false,
274
+ registered,
275
+ };
276
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Sync Operations - Pull + Diff for AGIRAILS.md
3
+ *
4
+ * Terraform-style sync: compare local AGIRAILS.md with on-chain state.
5
+ * Never auto-overwrites — shows diff and requires explicit confirmation.
6
+ *
7
+ * @module config/syncOperations
8
+ */
9
+
10
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
11
+ import { Provider } from 'ethers';
12
+ import { computeConfigHash, parseAgirailsMd, serializeAgirailsMd } from './agirailsmd';
13
+ import { validateCID } from '../utils/validation';
14
+ import { AgentRegistryClient } from '../registry/AgentRegistryClient';
15
+
16
+ // ============================================================================
17
+ // Constants
18
+ // ============================================================================
19
+
20
+ /** Public IPFS gateways for read-only access (no credentials needed) */
21
+ const IPFS_GATEWAYS = [
22
+ 'https://ipfs.io/ipfs/',
23
+ 'https://dweb.link/ipfs/',
24
+ 'https://cloudflare-ipfs.com/ipfs/',
25
+ ];
26
+
27
+ // ============================================================================
28
+ // Types
29
+ // ============================================================================
30
+
31
+ export interface DiffResult {
32
+ /** Whether local and on-chain are in sync */
33
+ inSync: boolean;
34
+ /** Local config hash (or null if no local file) */
35
+ localHash: string | null;
36
+ /** On-chain config hash (or zero hash if not published) */
37
+ onChainHash: string;
38
+ /** On-chain IPFS CID (or empty if not published) */
39
+ onChainCID: string;
40
+ /** Whether on-chain has a published config */
41
+ hasOnChainConfig: boolean;
42
+ /** Whether local file exists */
43
+ hasLocalFile: boolean;
44
+ /** Human-readable status message */
45
+ status: 'in-sync' | 'local-ahead' | 'remote-ahead' | 'diverged' | 'no-local' | 'no-remote';
46
+ }
47
+
48
+ export interface PullResult {
49
+ /** Whether a file was written */
50
+ written: boolean;
51
+ /** The pulled content (if any) */
52
+ content?: string;
53
+ /** IPFS CID that was fetched */
54
+ cid?: string;
55
+ /** Status message */
56
+ status: string;
57
+ }
58
+
59
+ export interface DiffOptions {
60
+ /** Path to local AGIRAILS.md */
61
+ path: string;
62
+ /** Agent address to check on-chain */
63
+ agentAddress: string;
64
+ /** AgentRegistry contract address */
65
+ registryAddress: string;
66
+ /** Provider for reading on-chain state */
67
+ provider: Provider;
68
+ }
69
+
70
+ export interface PullOptions extends DiffOptions {
71
+ /** Force overwrite without confirmation */
72
+ force?: boolean;
73
+ }
74
+
75
+ // ============================================================================
76
+ // Diff
77
+ // ============================================================================
78
+
79
+ const ZERO_HASH = '0x' + '0'.repeat(64);
80
+
81
+ /**
82
+ * Compare local AGIRAILS.md with on-chain config state.
83
+ *
84
+ * @param options - Diff configuration
85
+ * @returns Diff result showing sync status
86
+ */
87
+ export async function diff(options: DiffOptions): Promise<DiffResult> {
88
+ const { path, agentAddress, registryAddress, provider } = options;
89
+
90
+ // Read on-chain state
91
+ const registryClient = AgentRegistryClient.readOnly(registryAddress, provider);
92
+ const onChainState = await registryClient.getConfig(agentAddress);
93
+
94
+ const hasOnChainConfig = onChainState.configHash !== ZERO_HASH && onChainState.configCID !== '';
95
+ const onChainHash = onChainState.configHash;
96
+ const onChainCID = onChainState.configCID;
97
+
98
+ // Read local state
99
+ const hasLocalFile = existsSync(path);
100
+ let localHash: string | null = null;
101
+
102
+ if (hasLocalFile) {
103
+ const content = readFileSync(path, 'utf-8');
104
+ const { configHash } = computeConfigHash(content);
105
+ localHash = configHash;
106
+ }
107
+
108
+ // Determine status
109
+ let status: DiffResult['status'];
110
+ let inSync: boolean;
111
+
112
+ if (!hasLocalFile && !hasOnChainConfig) {
113
+ status = 'no-local';
114
+ inSync = true; // both empty = in sync
115
+ } else if (!hasLocalFile && hasOnChainConfig) {
116
+ status = 'remote-ahead';
117
+ inSync = false;
118
+ } else if (hasLocalFile && !hasOnChainConfig) {
119
+ status = 'no-remote';
120
+ inSync = false;
121
+ } else if (localHash === onChainHash) {
122
+ status = 'in-sync';
123
+ inSync = true;
124
+ } else {
125
+ // Both exist but hashes differ. Use the stored config_hash in frontmatter
126
+ // to determine directionality:
127
+ // - config_hash matches on-chain → user edited locally after last publish → local-ahead
128
+ // - config_hash doesn't match on-chain → remote was updated too → diverged
129
+ // - no config_hash → never published from this file → local-ahead
130
+ status = 'diverged';
131
+ inSync = false;
132
+
133
+ if (hasLocalFile) {
134
+ try {
135
+ const content = readFileSync(path, 'utf-8');
136
+ const { frontmatter } = parseAgirailsMd(content);
137
+ if (!frontmatter.config_hash) {
138
+ // Never published — local is the only source
139
+ status = 'local-ahead';
140
+ } else if (frontmatter.config_hash === onChainHash) {
141
+ // Last publish matches on-chain, so local edits are newer
142
+ status = 'local-ahead';
143
+ }
144
+ // else: frontmatter.config_hash !== onChainHash → remote updated → diverged
145
+ } catch {
146
+ // Parse error — keep as diverged
147
+ }
148
+ }
149
+ }
150
+
151
+ return {
152
+ inSync,
153
+ localHash,
154
+ onChainHash,
155
+ onChainCID,
156
+ hasOnChainConfig,
157
+ hasLocalFile,
158
+ status,
159
+ };
160
+ }
161
+
162
+ // ============================================================================
163
+ // IPFS Fetch (public gateway, no credentials needed)
164
+ // ============================================================================
165
+
166
+ /**
167
+ * Fetch content from IPFS using public gateways (no Filebase credentials needed).
168
+ * Tries multiple gateways with fallback.
169
+ *
170
+ * @param cid - IPFS CID to fetch (validated before use)
171
+ * @returns Raw content as string
172
+ * @throws InvalidCIDError if CID format is invalid
173
+ * @throws Error if all gateways fail
174
+ */
175
+ async function fetchFromIPFS(cid: string): Promise<string> {
176
+ // Validate CID format before hitting any gateway
177
+ validateCID(cid, 'onChainCID');
178
+
179
+ const errors: string[] = [];
180
+
181
+ for (const gateway of IPFS_GATEWAYS) {
182
+ try {
183
+ const response = await fetch(`${gateway}${cid}`, {
184
+ signal: AbortSignal.timeout(15_000),
185
+ });
186
+ if (!response.ok) {
187
+ errors.push(`${gateway}: HTTP ${response.status}`);
188
+ continue;
189
+ }
190
+ return await response.text();
191
+ } catch (err) {
192
+ errors.push(`${gateway}: ${err instanceof Error ? err.message : String(err)}`);
193
+ }
194
+ }
195
+
196
+ throw new Error(
197
+ `Failed to fetch CID ${cid} from all IPFS gateways:\n${errors.map(e => ` - ${e}`).join('\n')}`
198
+ );
199
+ }
200
+
201
+ // ============================================================================
202
+ // Pull
203
+ // ============================================================================
204
+
205
+ /**
206
+ * Pull on-chain config to local AGIRAILS.md.
207
+ *
208
+ * Downloads from IPFS via public gateways (no Filebase credentials needed),
209
+ * verifies integrity against on-chain configHash, then writes locally.
210
+ *
211
+ * @param options - Pull configuration
212
+ * @returns Pull result
213
+ */
214
+ export async function pull(options: PullOptions): Promise<PullResult> {
215
+ const { path, agentAddress, registryAddress, provider, force = false } = options;
216
+
217
+ // First, run diff
218
+ const diffResult = await diff({ path, agentAddress, registryAddress, provider });
219
+
220
+ if (!diffResult.hasOnChainConfig) {
221
+ return {
222
+ written: false,
223
+ status: 'No config published on-chain for this agent.',
224
+ };
225
+ }
226
+
227
+ if (diffResult.inSync) {
228
+ return {
229
+ written: false,
230
+ status: 'Already in sync. No changes needed.',
231
+ };
232
+ }
233
+
234
+ // Fetch raw AGIRAILS.md from IPFS (public gateway, no credentials)
235
+ const content = await fetchFromIPFS(diffResult.onChainCID);
236
+
237
+ // Integrity verification: hash downloaded content and compare with on-chain hash
238
+ const { configHash: downloadedHash } = computeConfigHash(content);
239
+ if (downloadedHash !== diffResult.onChainHash) {
240
+ return {
241
+ written: false,
242
+ cid: diffResult.onChainCID,
243
+ status: `Integrity check failed! Downloaded content hash (${downloadedHash}) does not match on-chain hash (${diffResult.onChainHash}). The IPFS content may have been tampered with.`,
244
+ };
245
+ }
246
+
247
+ // Check if local file exists and we're not forcing
248
+ if (diffResult.hasLocalFile && !force) {
249
+ return {
250
+ written: false,
251
+ content,
252
+ cid: diffResult.onChainCID,
253
+ status: `Remote config differs from local. Use --force to overwrite. CID: ${diffResult.onChainCID}`,
254
+ };
255
+ }
256
+
257
+ // Stamp on-chain metadata into frontmatter so diff heuristic can detect
258
+ // future remote changes (without this, pulled files have no config_hash
259
+ // and diff would always report "local-ahead" instead of "diverged")
260
+ const { frontmatter, body } = parseAgirailsMd(content);
261
+ const stamped = serializeAgirailsMd(
262
+ {
263
+ ...frontmatter,
264
+ config_hash: diffResult.onChainHash,
265
+ config_cid: diffResult.onChainCID,
266
+ },
267
+ body
268
+ );
269
+
270
+ // Write the stamped file
271
+ writeFileSync(path, stamped, 'utf-8');
272
+
273
+ return {
274
+ written: true,
275
+ content: stamped,
276
+ cid: diffResult.onChainCID,
277
+ status: `Pulled and verified config from IPFS (${diffResult.onChainCID}) → ${path}`,
278
+ };
279
+ }
package/src/index.ts CHANGED
@@ -176,6 +176,9 @@ export {
176
176
  createUsedAttestationTracker,
177
177
  } from './utils/UsedAttestationTracker';
178
178
 
179
+ // Wallet
180
+ export { resolvePrivateKey, getCachedAddress } from './wallet/keystore';
181
+
179
182
  // Helper utilities
180
183
  export {
181
184
  USDC,