Skip to content

Commit 769cb60

Browse files
author
azeth-sync[bot]
committed
v0.2.11: sync from monorepo 2026-06-03
1 parent 6560d6c commit 769cb60

9 files changed

Lines changed: 217 additions & 26 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.10",
3+
"version": "0.2.11",
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.10",
41+
"@azeth/common": "^0.2.11",
4242
"@x402/core": "^2.14.0",
4343
"@x402/extensions": "^2.14.0",
4444
"@xmtp/agent-sdk": "^2.2.0",

src/account/history.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,19 @@ export interface TransactionRecord {
2424
timestamp: number;
2525
}
2626

27+
/** Shape of a single record returned by the server's GET /api/v1/history.
28+
* The server wraps records in `{ data, meta }` and serializes the bigint
29+
* fields (value, blockNumber) as JSON-safe strings. */
30+
interface ServerHistoryRecord {
31+
hash: `0x${string}`;
32+
from: `0x${string}`;
33+
to: `0x${string}` | null;
34+
value: string;
35+
token: `0x${string}` | null;
36+
blockNumber: string;
37+
timestamp: number;
38+
}
39+
2740
/** Pad an address to a 32-byte hex topic for event log filtering */
2841
function addressToTopic(addr: `0x${string}`): `0x${string}` {
2942
return `0x000000000000000000000000${addr.slice(2).toLowerCase()}` as `0x${string}`;
@@ -51,7 +64,20 @@ export async function getHistory(
5164
try {
5265
const response = await withRetry(() => fetch(`${serverUrl}/api/v1/history?${queryParams}`));
5366
if (response.ok) {
54-
return await response.json() as TransactionRecord[];
67+
// The server returns { data, meta } (not a bare array) and serializes
68+
// bigint fields as strings. Unwrap the envelope and restore the bigint
69+
// types declared by TransactionRecord so callers (e.g. the MCP tool's
70+
// formatTokenAmount) receive the contract they expect.
71+
const body = await response.json() as { data?: ServerHistoryRecord[] };
72+
return (body.data ?? []).map((r) => ({
73+
hash: r.hash,
74+
from: r.from,
75+
to: r.to,
76+
value: BigInt(r.value),
77+
token: r.token,
78+
blockNumber: BigInt(r.blockNumber),
79+
timestamp: r.timestamp,
80+
}));
5581
}
5682
// Non-OK response — fall through to on-chain fallback
5783
} catch {

src/payments/smart-fetch.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,9 @@ export async function smartFetch402(
101101
const resolvedChain = chainName ?? chainIdToName(publicClient.chain?.id ?? 0) ?? 'baseSepolia' as SupportedChainName;
102102
const discoveryResult = await discoverServicesWithFallback(serverUrl, discoveryParams, publicClient, resolvedChain);
103103
const services = discoveryResult.entries
104-
.filter(s => !!s.endpoint)
104+
// Reject empty AND whitespace-only endpoints — registry entries with " "
105+
// are truthy but produce `fetch(" ")` → "Invalid URL", aborting the fallback (S-3).
106+
.filter(s => !!s.endpoint?.trim())
105107
.slice(0, maxRetries);
106108

107109
if (services.length === 0) {
@@ -126,8 +128,8 @@ export async function smartFetch402(
126128
for (let i = 0; i < services.length; i++) {
127129
const service = services[i]!;
128130

129-
// Skip services without an endpoint
130-
if (!service.endpoint) {
131+
// Skip services without a usable endpoint (empty or whitespace-only)
132+
if (!service.endpoint?.trim()) {
131133
failedServices.push({ service, error: 'No endpoint URL' });
132134
continue;
133135
}

src/payments/x402.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -311,8 +311,8 @@ export async function fetch402(
311311
// Check budget (default cap: 10 USDC if not specified)
312312
const effectiveMaxAmount = options?.maxAmount ?? DEFAULT_MAX_AMOUNT;
313313
if (amount > effectiveMaxAmount) {
314-
const requiredFmt = formatTokenAmount(amount, 6, 2);
315-
const maxFmt = formatTokenAmount(effectiveMaxAmount, 6, 2);
314+
const requiredFmt = formatTokenAmount(amount, 6);
315+
const maxFmt = formatTokenAmount(effectiveMaxAmount, 6);
316316
throw new AzethError(
317317
`Payment of ${requiredFmt} USDC exceeds maximum of ${maxFmt} USDC`,
318318
'BUDGET_EXCEEDED',
@@ -683,8 +683,8 @@ async function attemptSmartAccountPayment(
683683
// Budget check
684684
const effectiveMaxAmount = options.maxAmount ?? DEFAULT_MAX_AMOUNT;
685685
if (amount > effectiveMaxAmount) {
686-
const requiredFmt = formatTokenAmount(amount, 6, 2);
687-
const maxFmt = formatTokenAmount(effectiveMaxAmount, 6, 2);
686+
const requiredFmt = formatTokenAmount(amount, 6);
687+
const maxFmt = formatTokenAmount(effectiveMaxAmount, 6);
688688
throw new AzethError(
689689
`Payment of ${requiredFmt} USDC exceeds maximum of ${maxFmt} USDC`,
690690
'BUDGET_EXCEEDED',

src/utils/userop.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
entryPoint07Abi,
1717
entryPoint07Address,
1818
getUserOperationHash,
19+
prepareUserOperation,
1920
} from 'viem/account-abstraction';
2021
import { createSmartAccountClient, type SmartAccountClient as PermissionlessSmartAccountClient } from 'permissionless';
2122
import { AzethAccountAbi } from '@azeth/common/abis';
@@ -194,6 +195,30 @@ export async function createAzethSmartAccount(
194195
});
195196
}
196197

198+
/**
199+
* Multiplier (numerator / denominator) applied to the bundler's estimated
200+
* `verificationGasLimit`.
201+
*
202+
* GuardianModule.validateUserOp burns a STATE-DEPENDENT amount of verification
203+
* gas: oracle staticcalls, a daily-spend SSTORE (cold ~20k / warm ~5k), and a
204+
* conditional epoch reset. The bundler estimates against on-chain state at
205+
* estimation time, but that slot can be cold (or the epoch can roll over) by
206+
* execution time, so the real verification cost can exceed a tight point
207+
* estimate — deterministically reverting with `AA26 over verificationGasLimit`.
208+
* A 1.5x buffer absorbs that variance; unused gas is refunded by the EntryPoint,
209+
* so over-provisioning is safe. This restores the headroom lost when the flat
210+
* 300K override was removed alongside the v22 GuardianModule estimation fix, but
211+
* as a proportional multiplier rather than a magic constant.
212+
*/
213+
const VERIFICATION_GAS_BUFFER_NUMERATOR = 3n;
214+
const VERIFICATION_GAS_BUFFER_DENOMINATOR = 2n;
215+
216+
/** Apply the verification-gas safety buffer to a bundler estimate.
217+
* See {@link VERIFICATION_GAS_BUFFER_NUMERATOR} for why this is needed. */
218+
export function applyVerificationGasBuffer(verificationGasLimit: bigint): bigint {
219+
return (verificationGasLimit * VERIFICATION_GAS_BUFFER_NUMERATOR) / VERIFICATION_GAS_BUFFER_DENOMINATOR;
220+
}
221+
197222
/**
198223
* Create a permissionless SmartAccountClient for an AzethAccount.
199224
*
@@ -273,6 +298,20 @@ export async function createAzethSmartAccountClient(
273298
chain: publicClient.chain,
274299
bundlerTransport: http(resolvedBundlerUrl),
275300
client: publicClient,
301+
// Absorb state-dependent GuardianModule verification-gas variance (see
302+
// applyVerificationGasBuffer). Wrapping prepareUserOperation applies the
303+
// buffer to EVERY UserOp submitted through this client — value transfers,
304+
// agreement executions, x402 settlement — so a cold daily-spend slot can't
305+
// deterministically AA26 a funded account's first value-spend.
306+
userOperation: {
307+
prepareUserOperation: async (client, parameters) => {
308+
const prepared = await prepareUserOperation(client, parameters);
309+
return {
310+
...prepared,
311+
verificationGasLimit: applyVerificationGasBuffer(prepared.verificationGasLimit),
312+
};
313+
},
314+
},
276315
};
277316

278317
// Wire paymaster middleware when URL is available.

test/account/history.test.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,47 @@ describe('account/history', () => {
1616
});
1717

1818
describe('server-based history (with serverUrl)', () => {
19-
it('should fetch history from the server API', async () => {
20-
const mockRecords = [
21-
{
22-
hash: '0xabc' as `0x${string}`,
23-
from: TEST_ACCOUNT,
24-
to: '0x1234' as `0x${string}`,
25-
value: 100n,
26-
blockNumber: 50n,
27-
timestamp: 1700000000,
28-
},
29-
];
19+
it('parses the { data, meta } envelope and restores bigint fields (F-5)', async () => {
20+
// The real server wraps records in { data, meta } and serializes the
21+
// bigint fields (value, blockNumber) as JSON strings. The SDK previously
22+
// cast the whole body to TransactionRecord[], so it returned the wrapper
23+
// object instead of the records (and string-typed values).
24+
const serverBody = {
25+
data: [
26+
{
27+
hash: '0xabc' as `0x${string}`,
28+
from: TEST_ACCOUNT,
29+
to: '0x1234' as `0x${string}`,
30+
value: '100',
31+
token: null,
32+
type: 'transfer',
33+
timestamp: 1700000000,
34+
blockNumber: '50',
35+
},
36+
],
37+
meta: { count: 1, nextCursor: undefined },
38+
};
3039

3140
globalThis.fetch = vi.fn().mockResolvedValue(
32-
createMockResponse(200, mockRecords),
41+
createMockResponse(200, serverBody),
3342
);
3443

3544
const result = await getHistory(publicClient, TEST_ACCOUNT, 'https://api.azeth.ai');
3645

37-
expect(result).toEqual(mockRecords);
46+
expect(result).toEqual([
47+
{
48+
hash: '0xabc',
49+
from: TEST_ACCOUNT,
50+
to: '0x1234',
51+
value: 100n,
52+
token: null,
53+
blockNumber: 50n,
54+
timestamp: 1700000000,
55+
},
56+
]);
57+
// bigint fields restored from JSON strings
58+
expect(result[0]?.value).toBe(100n);
59+
expect(result[0]?.blockNumber).toBe(50n);
3860
expect(globalThis.fetch).toHaveBeenCalledWith(
3961
expect.stringContaining('https://api.azeth.ai/api/v1/history'),
4062
);

test/payments/smart-fetch.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,29 @@ describe('smartFetch402 (standalone routing layer)', () => {
202202
expect(result.failedServices).toBeUndefined();
203203
});
204204

205+
it('should filter out whitespace-only endpoints before attempting (S-3)', async () => {
206+
const spaceEndpoint = makeService({ tokenId: 1n, name: 'SpaceEndpoint', endpoint: ' ' });
207+
const blankEndpoint = makeService({ tokenId: 2n, name: 'BlankEndpoint', endpoint: ' ' });
208+
const withEndpoint = makeService({ tokenId: 3n, name: 'HasEndpoint', endpoint: 'https://good.example.com' });
209+
mockedDiscover.mockResolvedValueOnce({ entries: [spaceEndpoint, blankEndpoint, withEndpoint], source: 'server' });
210+
mockedFetch402.mockResolvedValueOnce(makeFetch402Result());
211+
212+
const result = await smartFetch402(
213+
publicClient as any, walletClient as any, TEST_OWNER, SERVER_URL,
214+
'price-feed',
215+
);
216+
217+
// The whitespace endpoints are truthy but would make fetch(" ") throw "Invalid
218+
// URL"; they must be skipped so the valid service is the one attempted.
219+
expect(result.service.name).toBe('HasEndpoint');
220+
expect(result.attemptsCount).toBe(1);
221+
expect(mockedFetch402).toHaveBeenCalledTimes(1);
222+
expect(mockedFetch402).toHaveBeenCalledWith(
223+
expect.anything(), expect.anything(), TEST_OWNER, 'https://good.example.com',
224+
expect.anything(),
225+
);
226+
});
227+
205228
it('should move preferredService to the front', async () => {
206229
const service1 = makeService({ tokenId: 1n, name: 'First', reputation: 95 });
207230
const service2 = makeService({ tokenId: 2n, name: 'Preferred', reputation: 80, endpoint: 'https://preferred.example.com' });

test/payments/x402.test.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,23 @@ describe('payments/x402', () => {
180180
fetch402(publicClient, walletClient, TEST_ACCOUNT, testUrl, {
181181
maxAmount: 500000n, // 0.5 USDC, less than the 1 USDC required
182182
}),
183-
).rejects.toThrow('Payment of 1 USDC exceeds maximum of 0.50 USDC');
183+
).rejects.toThrow('Payment of 1 USDC exceeds maximum of 0.5 USDC');
184+
});
185+
186+
it('shows sub-cent maxAmount at full precision in the error (F-8)', async () => {
187+
globalThis.fetch = vi.fn().mockResolvedValue(
188+
createMockResponse(402, null, {
189+
'X-Payment-Required': JSON.stringify(paymentRequirement),
190+
}),
191+
);
192+
193+
// 0.009 USDC used to round to "0.01" (self-contradictory: "0.01 exceeds 0.01"),
194+
// and 0.001 USDC rounded to "0". Full precision shows the real cap.
195+
await expect(
196+
fetch402(publicClient, walletClient, TEST_ACCOUNT, testUrl, {
197+
maxAmount: 9000n, // 0.009 USDC
198+
}),
199+
).rejects.toThrow('Payment of 1 USDC exceeds maximum of 0.009 USDC');
184200
});
185201

186202
it('should not throw when payment is within maxAmount budget', async () => {
@@ -595,7 +611,8 @@ describe('payments/x402', () => {
595611
smartAccountTransfer: mockSmartAccountTransfer,
596612
maxAmount: 500000n, // 0.5 USDC, less than the 1 USDC required
597613
}),
598-
).rejects.toThrow('Payment of 1 USDC exceeds maximum of 0.50 USDC');
614+
).rejects.toThrow('Payment of 1 USDC exceeds maximum of 0.5 USDC');
599615
});
616+
600617
});
601618
});

test/utils/userop.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const {
66
mockToSmartAccount,
77
mockGetPaymasterData,
88
mockGetPaymasterStubData,
9+
mockPrepareUserOperation,
910
} = vi.hoisted(() => ({
1011
mockCreateSmartAccountClient: vi.fn().mockReturnValue({
1112
sendTransaction: vi.fn(),
@@ -19,6 +20,7 @@ const {
1920
}),
2021
mockGetPaymasterData: vi.fn().mockResolvedValue({ paymaster: '0xPaymaster', paymasterData: '0xdata' }),
2122
mockGetPaymasterStubData: vi.fn().mockResolvedValue({ paymaster: '0xPaymaster', paymasterData: '0xstubdata' }),
23+
mockPrepareUserOperation: vi.fn(),
2224
}));
2325

2426
vi.mock('permissionless', () => ({
@@ -30,6 +32,7 @@ vi.mock('viem/account-abstraction', () => ({
3032
entryPoint07Abi: [],
3133
entryPoint07Address: '0x0000000071727De22E5E9d8BAf0edAc6f37da032',
3234
getUserOperationHash: vi.fn().mockReturnValue('0x' + '00'.repeat(32)),
35+
prepareUserOperation: mockPrepareUserOperation,
3336
}));
3437

3538
vi.mock('viem', async (importOriginal) => {
@@ -53,7 +56,7 @@ vi.mock('@azeth/common/abis', () => ({
5356
AzethAccountAbi: [],
5457
}));
5558

56-
import { createAzethSmartAccountClient } from '../../src/utils/userop.js';
59+
import { createAzethSmartAccountClient, applyVerificationGasBuffer } from '../../src/utils/userop.js';
5760
import type { PublicClient, WalletClient, Transport, Chain, Account } from 'viem';
5861

5962
function mockPublicClient(overrides: Record<string, unknown> = {}): PublicClient<Transport, Chain> {
@@ -212,4 +215,63 @@ describe('createAzethSmartAccountClient', () => {
212215
}),
213216
).rejects.toThrow('bundlerUrl is required');
214217
});
218+
219+
// F-2: native value-spends deterministically AA26'd because the SDK took the
220+
// bundler's verificationGasLimit estimate verbatim, leaving no headroom for
221+
// GuardianModule's state-dependent validation gas (cold daily-spend SSTORE,
222+
// epoch reset). The client must install a prepareUserOperation hook that
223+
// buffers verificationGasLimit for EVERY UserOp.
224+
it('installs a prepareUserOperation hook that buffers verificationGasLimit (F-2)', async () => {
225+
mockPrepareUserOperation.mockResolvedValue({
226+
sender: TEST_SMART_ACCOUNT,
227+
nonce: 1n,
228+
callData: '0x',
229+
callGasLimit: 50_000n,
230+
verificationGasLimit: 100_000n,
231+
preVerificationGas: 40_000n,
232+
maxFeePerGas: 1n,
233+
maxPriorityFeePerGas: 1n,
234+
signature: '0x',
235+
});
236+
237+
await createAzethSmartAccountClient({
238+
publicClient: mockPublicClient(),
239+
walletClient: mockWalletClient(),
240+
smartAccountAddress: TEST_SMART_ACCOUNT,
241+
bundlerUrl: TEST_BUNDLER_URL,
242+
});
243+
244+
const callArgs = mockCreateSmartAccountClient.mock.calls[0][0];
245+
expect(callArgs.userOperation).toBeDefined();
246+
expect(callArgs.userOperation.prepareUserOperation).toBeTypeOf('function');
247+
248+
// Invoke the installed hook: it must delegate to viem's prepareUserOperation
249+
// and return the SAME op with only verificationGasLimit scaled up 1.5x.
250+
const fakeClient = {} as never;
251+
const fakeParams = {} as never;
252+
const prepared = await callArgs.userOperation.prepareUserOperation(fakeClient, fakeParams);
253+
254+
expect(mockPrepareUserOperation).toHaveBeenCalledWith(fakeClient, fakeParams);
255+
expect(prepared.verificationGasLimit).toBe(150_000n); // 100k * 3/2
256+
// Every other field passes through untouched.
257+
expect(prepared.callGasLimit).toBe(50_000n);
258+
expect(prepared.preVerificationGas).toBe(40_000n);
259+
expect(prepared.nonce).toBe(1n);
260+
});
261+
});
262+
263+
describe('applyVerificationGasBuffer', () => {
264+
it('scales the estimate by 1.5x (the AA26 fix for F-2)', () => {
265+
// The exact verificationGasLimit (101136) the bundler returned for the
266+
// transfer that deterministically reverted with AA26 in the MCP test.
267+
expect(applyVerificationGasBuffer(101_136n)).toBe(151_704n);
268+
expect(applyVerificationGasBuffer(100_000n)).toBe(150_000n);
269+
expect(applyVerificationGasBuffer(0n)).toBe(0n);
270+
});
271+
272+
it('always returns at least the input (headroom is never negative)', () => {
273+
for (const estimate of [1n, 12_345n, 80_000n, 101_136n, 500_000n]) {
274+
expect(applyVerificationGasBuffer(estimate)).toBeGreaterThanOrEqual(estimate);
275+
}
276+
});
215277
});

0 commit comments

Comments
 (0)