Skip to content

Commit 9c6f389

Browse files
author
azeth-sync[bot]
committed
v0.2.24: sync from monorepo 2026-07-02
1 parent d588c2e commit 9c6f389

5 files changed

Lines changed: 87 additions & 10 deletions

File tree

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@azeth/sdk",
3-
"version": "0.2.23",
3+
"version": "0.2.24",
44
"type": "module",
55
"description": "TypeScript SDK for the Azeth trust infrastructure — smart accounts, x402 payments, reputation, and service discovery",
66
"license": "MIT",
@@ -38,7 +38,7 @@
3838
"test:mutation": "npx stryker run"
3939
},
4040
"dependencies": {
41-
"@azeth/common": "^0.2.23",
41+
"@azeth/common": "^0.2.24",
4242
"@x402/core": "^2.14.0",
4343
"@x402/extensions": "^2.14.0",
4444
"@xmtp/agent-sdk": "^2.2.0",

src/payments/smart-fetch.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,27 @@ export async function smartFetch402(
145145
sortByReputation: true,
146146
minReputation: options?.minReputation,
147147
entityType: options?.entityType,
148-
limit: maxRetries * 3,
148+
// R4-3: the usable-endpoint filter below runs AFTER this fetch, and registries carry
149+
// many entries with blank/placeholder/tunnel endpoints — a small fetch window can cut
150+
// off every usable provider before the filter sees it. Fetch a wide window (one cheap
151+
// indexed query) and only then narrow to maxRetries attempts.
152+
limit: Math.min(Math.max(maxRetries * 3, 50), 100),
149153
};
150154

151155
const resolvedChain = chainName ?? chainIdToName(publicClient.chain?.id ?? 0) ?? 'baseSepolia' as SupportedChainName;
152156
const discoveryResult = await discoverServicesWithFallback(serverUrl, discoveryParams, publicClient, resolvedChain);
157+
158+
// R4-2: a reputation floor is a TRUST decision — it must fail closed. The on-chain
159+
// discovery fallback cannot verify reputation (minReputationIgnored), so candidates from
160+
// that path are unvetted against the caller's threshold and must not be paid.
161+
if (options?.minReputation !== undefined && discoveryResult.minReputationIgnored) {
162+
throw new AzethError(
163+
`Cannot verify the minReputation=${options.minReputation} floor: the reputation-indexed discovery service is unavailable and on-chain fallback discovery cannot rank by reputation. Retry without minReputation, or retry later.`,
164+
'SERVICE_NOT_FOUND',
165+
{ capability, minReputation: options.minReputation, reason: 'REPUTATION_FLOOR_UNVERIFIABLE' },
166+
);
167+
}
168+
153169
const services = discoveryResult.entries
154170
// Reject empty AND whitespace-only endpoints — registry entries with " "
155171
// are truthy but produce `fetch(" ")` → "Invalid URL", aborting the fallback (S-3).
@@ -158,7 +174,9 @@ export async function smartFetch402(
158174

159175
if (services.length === 0) {
160176
throw new AzethError(
161-
`No services found for capability "${capability}"`,
177+
options?.minReputation !== undefined
178+
? `No services with a usable endpoint meet minReputation=${options.minReputation} for capability "${capability}"`
179+
: `No services found for capability "${capability}"`,
162180
'SERVICE_NOT_FOUND',
163181
{ capability, minReputation: options?.minReputation },
164182
);
@@ -375,10 +393,20 @@ export async function smartFetch402(
375393
// All services failed — or, when an intent was given, it couldn't be resolved against any
376394
// provider's catalog. In the latter case we attach the menu (`options`) so the agent can
377395
// refine its intent/params and retry in ONE free round-trip instead of hitting a dead end. (F6)
396+
// R4-4: the per-service causes are inlined into the MESSAGE (not only `details`, which
397+
// some presentation layers drop) so "all failed" is never the whole story the agent sees.
398+
const failureSummary = failedServices
399+
.filter(f => !f.error.startsWith('intent unresolved') && !f.error.startsWith('catalog returned'))
400+
.slice(0, 3)
401+
.map(f => `${f.service.name}: ${f.error}`)
402+
.join('; ');
403+
const catalogMsg = hasIntent
404+
? `Could not resolve your intent for capability "${capability}". See "options" for the available catalog entries and their valid params, then retry with matching intent/params.`
405+
: `Providers for capability "${capability}" serve a catalog of priced routes — pass "intent" or "params" to select one. See "options" for the catalog entries and their valid params.`;
378406
throw new AzethError(
379407
catalogOptions.length > 0
380-
? `Could not resolve your intent for capability "${capability}". See "options" for the available catalog entries and their valid params, then retry with matching intent/params.`
381-
: `All ${services.length} services for capability "${capability}" failed`,
408+
? failureSummary ? `${catalogMsg} Other attempted service(s) failed — ${failureSummary}` : catalogMsg
409+
: `All ${services.length} service(s) for capability "${capability}" failed${failureSummary}`,
382410
'SERVICE_NOT_FOUND',
383411
{
384412
capability,

src/registry/discover.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,10 @@ async function discoverServicesWithFallbackInner(
243243
const entries = await discoverServices(serverUrl, params);
244244
// Fall back to on-chain when server returns empty results — the server index may be
245245
// stale or incomplete, but the on-chain registry is the source of truth.
246-
if (entries.length > 0) {
246+
// EXCEPTION (R4-2): when a minReputation floor was requested, an empty-but-successful
247+
// server answer is AUTHORITATIVE ("no service meets the threshold") — the on-chain
248+
// fallback cannot verify reputation and would reintroduce unfiltered candidates.
249+
if (entries.length > 0 || params.minReputation !== undefined) {
247250
return { entries, source: 'server' };
248251
}
249252
} catch (err: unknown) {

test/payments/smart-fetch.test.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,13 @@ describe('smartFetch402 (standalone routing layer)', () => {
8585
expect(result.paymentMade).toBe(true);
8686
expect(result.failedServices).toBeUndefined();
8787

88-
// Verify discovery was called with sortByReputation
88+
// Verify discovery was called with sortByReputation and a WIDE fetch window (R4-3:
89+
// the usable-endpoint filter runs after this fetch, so a narrow window can cut off
90+
// every usable provider among blank/tunnel-endpoint registrations)
8991
expect(mockedDiscover).toHaveBeenCalledWith(SERVER_URL, expect.objectContaining({
9092
capability: 'price-feed',
9193
sortByReputation: true,
92-
limit: 9,
94+
limit: 50,
9395
}), expect.anything(), expect.anything());
9496
});
9597

@@ -188,10 +190,38 @@ describe('smartFetch402 (standalone routing layer)', () => {
188190
);
189191

190192
expect(mockedDiscover).toHaveBeenCalledWith(SERVER_URL, expect.objectContaining({
191-
limit: 15,
193+
limit: 50, // floor of 50 dominates maxRetries*3 (R4-3)
192194
}), expect.anything(), expect.anything());
193195
});
194196

197+
it('fails closed when a minReputation floor cannot be verified (on-chain fallback, R4-2)', async () => {
198+
const unvetted = makeService({ tokenId: 1550n, name: 'ZeroRepCatalog', reputation: 0 });
199+
mockedDiscover.mockResolvedValueOnce({ entries: [unvetted], source: 'on-chain', minReputationIgnored: true });
200+
201+
await expect(
202+
smartFetch402(
203+
publicClient as any, walletClient as any, TEST_OWNER, SERVER_URL,
204+
'price-feed', { minReputation: 90 },
205+
),
206+
).rejects.toThrow(/Cannot verify the minReputation=90 floor/);
207+
208+
// The unvetted candidate must never be paid or even fetched
209+
expect(mockedFetch402).not.toHaveBeenCalled();
210+
});
211+
212+
it('inlines per-service failure causes into the all-failed error message (R4-4)', async () => {
213+
const service = makeService({ name: 'BrokeService' });
214+
mockedDiscover.mockResolvedValueOnce({ entries: [service], source: 'server' });
215+
mockedFetch402.mockRejectedValueOnce(new AzethError('Insufficient USDC balance: have 0, need 0.01', 'INSUFFICIENT_BALANCE'));
216+
217+
await expect(
218+
smartFetch402(
219+
publicClient as any, walletClient as any, TEST_OWNER, SERVER_URL,
220+
'price-feed',
221+
),
222+
).rejects.toThrow(/BrokeService: Insufficient USDC balance/);
223+
});
224+
195225
it('should pre-filter services without an endpoint', async () => {
196226
const noEndpoint = makeService({ tokenId: 1n, name: 'NoEndpoint', endpoint: undefined });
197227
const withEndpoint = makeService({ tokenId: 2n, name: 'HasEndpoint', endpoint: 'https://good.test-svc.io' });

test/registry/discover.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,22 @@ describe('registry/discover', () => {
173173
expect(result.entries).toEqual(mockEntries);
174174
});
175175

176+
it('treats an empty server result as authoritative when minReputation is set (R4-2)', async () => {
177+
globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse(200, { data: [] }));
178+
const publicClient = createMockPublicClient();
179+
180+
const result = await discoverServicesWithFallback(
181+
serverUrl, { capability: 'data', minReputation: 90 }, publicClient, 'baseSepolia',
182+
);
183+
184+
// "No service meets the threshold" IS the answer — the on-chain fallback cannot
185+
// verify reputation and would reintroduce unvetted candidates.
186+
expect(result.source).toBe('server');
187+
expect(result.entries).toEqual([]);
188+
expect(result.minReputationIgnored).toBeUndefined();
189+
expect(publicClient.readContract).not.toHaveBeenCalled();
190+
});
191+
176192
/** Helper: build a RegistrySnapshot tuple for oracle mock responses */
177193
function makeSnapshot(
178194
tokenId: bigint,

0 commit comments

Comments
 (0)