Skip to content

Commit 4dac366

Browse files
feat: add txHashResolver support for AA wallets in StoryClient (#695)
* feat: add txHashResolver support for AA wallets in StoryClient Support optional txHashResolver in StoryClient and all factory methods to resolve userOp hashes before waiting for transaction receipts. Add unit/integration coverage for resolver wiring and raw userOp wallet behavior (including value/fee forwarding), and stabilize oov3 error-path unit test stubbing. * feat: add txHashResolver for AA wallets and fix flaky integration tests
1 parent e5780c9 commit 4dac366

File tree

13 files changed

+1013
-41
lines changed

13 files changed

+1013
-41
lines changed

.github/workflows/pr-internal.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,4 @@ jobs:
1919
with:
2020
sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
2121
ENVIRONMENT: "odyssey"
22-
secrets:
23-
WALLET_PRIVATE_KEY: ${{ secrets.WALLET_PRIVATE_KEY }}
24-
TEST_WALLET_ADDRESS: ${{ secrets.TEST_WALLET_ADDRESS }}
22+
secrets: inherit

docs/v1.4.4-release.md

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# v1.4.4 Release Notes
2+
3+
## New Features
4+
5+
### Account Abstraction (AA) Wallet Support — `txHashResolver`
6+
7+
Added support for Account Abstraction wallets (e.g. **ZeroDev**, **Dynamic Global Wallet**) via a new optional `txHashResolver` configuration parameter.
8+
9+
#### Problem
10+
11+
When using AA wallets, `writeContract` returns a **UserOperation hash** instead of a standard **transaction hash**. The SDK's `waitForTransactionReceipt` only works with regular transaction hashes, causing AA wallet transactions to fail silently or hang.
12+
13+
#### Solution
14+
15+
A new `txHashResolver` option can be passed when creating a `StoryClient`. This function resolves UserOperation hashes into on-chain transaction hashes before the SDK waits for receipts. The resolver is applied transparently — **all existing SDK methods work without any changes**.
16+
17+
#### Usage
18+
19+
**Normal wallets** — no changes needed:
20+
21+
```typescript
22+
const client = StoryClient.newClient({
23+
transport: http("https://aeneid.storyrpc.io"),
24+
account: privateKeyToAccount("0x..."),
25+
});
26+
```
27+
28+
**ZeroDev AA wallet — Using Kernel Client directly (recommended)**:
29+
30+
ZeroDev's `createKernelAccountClient` returns a viem smart account client whose `writeContract` internally sends the UserOperation, waits for the bundler to include it on-chain, and returns the **real tx hash**. Therefore **`txHashResolver` is NOT needed** — simply pass the kernel client as the wallet:
31+
32+
```typescript
33+
import { createKernelAccountClient } from "@zerodev/sdk";
34+
35+
const kernelClient = await createKernelAccountClient({ /* ... */ });
36+
37+
const client = StoryClient.newClientUseWallet({
38+
transport: http("https://aeneid.storyrpc.io"),
39+
wallet: kernelClient,
40+
// No txHashResolver needed — kernel client handles it internally
41+
});
42+
43+
// All SDK methods work as usual
44+
const result = await client.ipAsset.mintAndRegisterIpAssetWithPilTerms({
45+
spgNftContract: "0x...",
46+
terms: [],
47+
});
48+
```
49+
50+
**Raw UserOp wallet + txHashResolver (for AA wallets that return userOpHash)**:
51+
52+
Some AA wallet integrations return the **UserOperation hash** directly from `writeContract` without internally waiting for the bundler receipt. For these wallets, configure `txHashResolver` to convert the userOpHash into the real on-chain tx hash:
53+
54+
```typescript
55+
const client = StoryClient.newClientUseWallet({
56+
transport: http("https://aeneid.storyrpc.io"),
57+
wallet: rawAAWallet, // writeContract returns userOpHash
58+
txHashResolver: async (userOpHash) => {
59+
const receipt = await bundlerClient.waitForUserOperationReceipt({
60+
hash: userOpHash,
61+
});
62+
return receipt.receipt.transactionHash;
63+
},
64+
});
65+
```
66+
67+
> **How to tell?** If the hash returned by the AA wallet's `writeContract` cannot be found as a transaction on a block explorer, it is a userOpHash and you need `txHashResolver`. If it can be found on-chain directly, the wallet already handles resolution internally and no resolver is needed.
68+
69+
**Dynamic Global Wallet**:
70+
71+
```typescript
72+
const client = StoryClient.newClientUseWallet({
73+
transport: http("https://aeneid.storyrpc.io"),
74+
wallet: dynamicWalletClient,
75+
txHashResolver: async (userOpHash) => {
76+
// Use Dynamic's bundler client to resolve the hash
77+
const receipt = await dynamicBundlerClient.waitForUserOperationReceipt({
78+
hash: userOpHash,
79+
});
80+
return receipt.receipt.transactionHash;
81+
},
82+
});
83+
```
84+
85+
#### Supported Factory Methods
86+
87+
`txHashResolver` is supported by all three client creation methods:
88+
89+
- `StoryClient.newClient({ ..., txHashResolver })`
90+
- `StoryClient.newClientUseWallet({ ..., txHashResolver })`
91+
- `StoryClient.newClientUseAccount({ ..., txHashResolver })`
92+
93+
#### API Reference
94+
95+
```typescript
96+
/**
97+
* A function that resolves a hash returned by writeContract into an actual
98+
* transaction hash that can be used with waitForTransactionReceipt.
99+
*
100+
* @param hash - The hash returned by writeContract (could be a userOpHash or txHash)
101+
* @returns The resolved on-chain transaction hash
102+
*/
103+
type TxHashResolver = (hash: Hash) => Promise<Hash>;
104+
```
105+
106+
## Changed Files
107+
108+
| File | Change |
109+
|------|--------|
110+
| `packages/core-sdk/src/types/config.ts` | Added `TxHashResolver` type; added `txHashResolver` to `StoryConfig`, `UseWalletStoryConfig`, `UseAccountStoryConfig` |
111+
| `packages/core-sdk/src/client.ts` | Added `applyTxHashResolver()` method; patched `rpcClient.waitForTransactionReceipt` when resolver is provided; forwarded resolver in factory methods |
112+
| `packages/core-sdk/test/unit/client.test.ts` | Added unit tests that verify resolver wiring, hash transformation, and constructor-time patching |
113+
| `packages/core-sdk/test/integration/txHashResolver.test.ts` | ZeroDev E2E integration tests (6 passing), covering both AA wallet modes |
114+
| `packages/core-sdk/package.json` | Added `@zerodev/sdk`, `@zerodev/ecdsa-validator` as devDependencies |
115+
| `packages/core-sdk/src/utils/oov3.ts` | Added retry logic (up to 5 attempts) to `settleAssertion()` for `Assertion not expired` errors |
116+
| `packages/core-sdk/test/unit/utils/oov3.test.ts` | Added `chai-as-promised`; added unit tests for retry success and max-retries failure |
117+
| `packages/core-sdk/test/integration/dispute.test.ts` | Increased assertion expiry wait from 3s to 15s |
118+
| `packages/core-sdk/test/integration/group.test.ts` | Added `this.retries(2)` to flaky royalty/reward tests |
119+
| `packages/core-sdk/test/integration/ipAsset.test.ts` | Added `this.retries(2)` to batch register test |
120+
| `.github/workflows/pr-internal.yml` | Changed to `secrets: inherit` to forward all repository secrets |
121+
122+
## CI / Test Stability Improvements
123+
124+
This release also includes fixes for 5 flaky integration tests that were failing due to testnet timing and nonce contention issues. These failures were **pre-existing** and unrelated to the `txHashResolver` feature.
125+
126+
### Fix #1 — Dispute: `Assertion not expired`
127+
128+
**Root cause**: `settleAssertion` was called before the on-chain assertion liveness period had actually expired. The previous 3-second wait was insufficient given variable testnet block times.
129+
130+
**Fix**:
131+
- `src/utils/oov3.ts``settleAssertion()` now retries up to 5 times (with 3s delay) when encountering `Assertion not expired`, instead of failing immediately.
132+
- `test/integration/dispute.test.ts` — Increased the post-dispute wait from **3s → 15s** in both `beforeEach` and the multicall tagging test.
133+
- `test/unit/utils/oov3.test.ts` — Added unit tests for retry success and max-retries failure; fixed `chai-as-promised` import.
134+
135+
### Fix #2#4 — Group: `receiver IP not registered` + cascading failures
136+
137+
**Root cause**: Nonce contention from a shared test wallet caused intermittent transaction failures. When `payRoyaltyOnBehalf` failed in the "collect royalties" test, the subsequent "get claimable reward" and "claim reward" tests also failed because they depended on the state from the first test.
138+
139+
**Fix**:
140+
- `test/integration/group.test.ts` — Added `this.retries(2)` to all three tests (`collect royalties`, `get claimable reward`, `claim reward`). Mocha will automatically retry each test up to 2 additional times on failure.
141+
142+
### Fix #5 — IP Asset: `replacement transaction underpriced`
143+
144+
**Root cause**: Nonce collision — a pending transaction from a previous test occupied the same nonce with equal or higher gas, causing the RPC node to reject the replacement.
145+
146+
**Fix**:
147+
- `test/integration/ipAsset.test.ts` — Added `this.retries(2)` to the `should successfully register IP assets with multicall disabled` test.
148+
149+
### CI Workflow: `secrets: inherit`
150+
151+
- `.github/workflows/pr-internal.yml` — Changed from explicitly listing individual secrets to `secrets: inherit`, so all repository secrets (including `BUNDLER_RPC_URL`) are automatically forwarded to the reusable workflow.
152+
153+
## Test Coverage Notes
154+
155+
- **Unit tests**: Validate that `txHashResolver` is invoked before receipt polling and that the resolved hash is used for receipt lookup.
156+
- **Integration tests — Pass-through & Simulated**: Cover normal wallet pass-through and simulated AA hash mapping behavior.
157+
- **Integration tests — ZeroDev E2E (2 scenarios)**:
158+
- **Kernel client as wallet**: Verifies that `writeContract` returns a real txHash and the SDK works correctly without a resolver.
159+
- **Raw userOp wallet + txHashResolver**: Uses a custom wallet wrapper (whose `writeContract` returns a userOpHash) to verify end-to-end that the resolver converts the userOpHash into a real on-chain txHash.
160+
- ZeroDev E2E tests require `BUNDLER_RPC_URL` and `WALLET_PRIVATE_KEY` in `.env`. Tests are automatically skipped when these are not configured.

packages/core-sdk/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
"@types/mocha": "^10.0.2",
5151
"@types/node": "^20.8.2",
5252
"@types/sinon": "^10.0.18",
53+
"@zerodev/ecdsa-validator": "^5.4.9",
54+
"@zerodev/sdk": "^5.5.7",
5355
"c8": "^8.0.1",
5456
"chai": "^4.3.10",
5557
"chai-as-promised": "^7.1.1",

packages/core-sdk/src/client.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ import { NftClient } from "./resources/nftClient";
1111
import { PermissionClient } from "./resources/permission";
1212
import { RoyaltyClient } from "./resources/royalty";
1313
import { WipClient } from "./resources/wip";
14-
import { ChainIds, StoryConfig, UseAccountStoryConfig, UseWalletStoryConfig } from "./types/config";
14+
import {
15+
ChainIds,
16+
StoryConfig,
17+
TxHashResolver,
18+
UseAccountStoryConfig,
19+
UseWalletStoryConfig,
20+
} from "./types/config";
1521
import { chain, chainStringToViemChain, validateAddress } from "./utils/utils";
1622

1723
if (typeof process !== "undefined") {
@@ -52,6 +58,12 @@ export class StoryClient {
5258

5359
this.rpcClient = createPublicClient(clientConfig);
5460

61+
// If a txHashResolver is provided (e.g. for AA wallets like ZeroDev/Dynamic),
62+
// patch waitForTransactionReceipt to resolve the hash first.
63+
if (config.txHashResolver) {
64+
this.applyTxHashResolver(config.txHashResolver);
65+
}
66+
5567
if (this.config.wallet) {
5668
this.wallet = this.config.wallet;
5769
} else if (this.config.account) {
@@ -66,6 +78,23 @@ export class StoryClient {
6678
}
6779
}
6880

81+
/**
82+
* Patches rpcClient.waitForTransactionReceipt to first resolve the hash
83+
* through the provided resolver. This transparently supports AA wallets
84+
* where writeContract returns a UserOperation hash instead of a tx hash.
85+
*/
86+
private applyTxHashResolver(resolver: TxHashResolver): void {
87+
const originalWaitForReceipt = this.rpcClient.waitForTransactionReceipt.bind(
88+
this.rpcClient,
89+
);
90+
this.rpcClient.waitForTransactionReceipt = async (
91+
args,
92+
): ReturnType<PublicClient["waitForTransactionReceipt"]> => {
93+
const resolvedHash = await resolver(args.hash);
94+
return originalWaitForReceipt({ ...args, hash: resolvedHash });
95+
};
96+
}
97+
6998
private get chainId(): ChainIds {
7099
return this.config.chainId as ChainIds;
71100
}
@@ -86,6 +115,7 @@ export class StoryClient {
86115
chainId: config.chainId,
87116
transport: config.transport,
88117
wallet: config.wallet,
118+
txHashResolver: config.txHashResolver,
89119
});
90120
}
91121

@@ -97,6 +127,7 @@ export class StoryClient {
97127
account: config.account,
98128
chainId: config.chainId,
99129
transport: config.transport,
130+
txHashResolver: config.txHashResolver,
100131
});
101132
}
102133

packages/core-sdk/src/types/config.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Account, Address, Transport } from "viem";
1+
import { Account, Address, Hash, Transport } from "viem";
22

33
import { SimpleWalletClient } from "../abi/generated";
44

@@ -9,6 +9,34 @@ import { SimpleWalletClient } from "../abi/generated";
99
*/
1010
export type SupportedChainIds = "aeneid" | "mainnet" | ChainIds;
1111

12+
/**
13+
* A function that resolves a hash returned by `writeContract` into an actual
14+
* transaction hash that can be used with `waitForTransactionReceipt`.
15+
*
16+
* This is required when using Account Abstraction wallets (e.g. ZeroDev, Dynamic)
17+
* because `writeContract` returns a UserOperation hash instead of a regular
18+
* transaction hash. The resolver should wait for the UserOperation to be bundled
19+
* and return the resulting on-chain transaction hash.
20+
*
21+
* @example ZeroDev
22+
* ```typescript
23+
* const client = StoryClient.newClientUseWallet({
24+
* transport: http("https://..."),
25+
* wallet: kernelClient,
26+
* txHashResolver: async (userOpHash) => {
27+
* const receipt = await bundlerClient.waitForUserOperationReceipt({
28+
* hash: userOpHash,
29+
* });
30+
* return receipt.receipt.transactionHash;
31+
* },
32+
* });
33+
* ```
34+
*
35+
* @param hash - The hash returned by `writeContract` (could be a userOpHash or txHash)
36+
* @returns The resolved on-chain transaction hash
37+
*/
38+
export type TxHashResolver = (hash: Hash) => Promise<Hash>;
39+
1240
/**
1341
* Configuration for the SDK Client.
1442
*
@@ -23,6 +51,11 @@ export type UseAccountStoryConfig = {
2351
*/
2452
readonly chainId?: SupportedChainIds;
2553
readonly transport: Transport;
54+
/**
55+
* Optional resolver for Account Abstraction wallets.
56+
* @see TxHashResolver
57+
*/
58+
readonly txHashResolver?: TxHashResolver;
2659
};
2760

2861
export type UseWalletStoryConfig = {
@@ -34,6 +67,11 @@ export type UseWalletStoryConfig = {
3467
readonly chainId?: SupportedChainIds;
3568
readonly transport: Transport;
3669
readonly wallet: SimpleWalletClient;
70+
/**
71+
* Optional resolver for Account Abstraction wallets.
72+
* @see TxHashResolver
73+
*/
74+
readonly txHashResolver?: TxHashResolver;
3775
};
3876

3977
export type StoryConfig = {
@@ -46,6 +84,16 @@ export type StoryConfig = {
4684
readonly chainId?: SupportedChainIds;
4785
readonly wallet?: SimpleWalletClient;
4886
readonly account?: Account | Address;
87+
/**
88+
* Optional resolver for Account Abstraction wallets (e.g. ZeroDev, Dynamic).
89+
*
90+
* When provided, the SDK will call this function to resolve the hash returned
91+
* by `writeContract` into the actual on-chain transaction hash before waiting
92+
* for the transaction receipt.
93+
*
94+
* @see TxHashResolver
95+
*/
96+
readonly txHashResolver?: TxHashResolver;
4997
};
5098

5199
export type ContractAddress = { [key in SupportedChainIds]: Record<string, string> };

0 commit comments

Comments
 (0)