Skip to content

Commit f6dbeda

Browse files
committed
fix: enforce Design by Contract preconditions and add JSDoc annotations
Add @precondition, @postcondition, @throws, and @invariant JSDoc annotations to all public SDK methods matching the spec contracts. Add missing runtime precondition enforcement: - switchChain() now checks connected state before chain validation - execute() now validates params.chainId against supportedChains
1 parent b23fbe8 commit f6dbeda

10 files changed

Lines changed: 270 additions & 7 deletions

File tree

src/sdk/core/chain/explorer.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ type ExplorerParams =
88
/**
99
* Builds an explorer URL for a transaction, address, or block.
1010
*
11-
* Returns null if the chain is not found, has no explorer config, or the
12-
* requested path type (e.g. blockPath) is not defined for that explorer.
11+
* @precondition registry is a valid ChainRegistry
12+
* @precondition params.chainId identifies a chain, params contains exactly one of tx/address/block
13+
* @postcondition returns a fully qualified URL string, or null if chain/explorer/path not found
14+
* @postcondition if explorer.queryParams is defined, they are appended as URL search params
1315
*/
1416
export function getExplorerUrl(registry: ChainRegistry, params: ExplorerParams): string | null {
1517
const descriptor = registry.getChain(params.chainId)

src/sdk/core/chain/registry.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@ export interface ChainRegistry {
1818
/**
1919
* Creates an immutable ChainRegistry from the provided descriptors.
2020
*
21-
* Throws ChainRegistryConflictError at construction time if any two descriptors
22-
* share the same chainId or the same caip2Id.
21+
* @precondition no two descriptors share the same chainId
22+
* @precondition no two descriptors share the same caip2Id
23+
* @postcondition registry is immutable — lookups never mutate internal state
24+
* @postcondition getAllChains() returns a copy of the input array
25+
* @throws {ChainRegistryConflictError} if any two descriptors share the same chainId or caip2Id
2326
*/
2427
export function createChainRegistry(chains: ChainDescriptor[]): ChainRegistry {
2528
const byChainId = new Map<string | number, ChainDescriptor>()

src/sdk/core/evm/server-wallet.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ export interface EvmServerWalletConfig {
3131
/**
3232
* Creates a server-side EVM wallet adapter backed by a private key.
3333
* Returns no Provider — server wallets have no UI layer.
34+
*
35+
* @precondition config.privateKey is a valid hex-encoded private key
36+
* @precondition config.chain is a valid viem Chain
37+
* @postcondition returned adapter.chainType === 'evm'
38+
* @postcondition returned bundle has no Provider (server wallets have no UI)
39+
* @invariant adapter.chainType never changes after construction
40+
* @invariant adapter.supportedChains never changes after construction
41+
* @invariant getStatus().connected === true (always connected)
3442
*/
3543
export function createEvmServerWallet(config: EvmServerWalletConfig): WalletAdapterBundle {
3644
const account = privateKeyToAccount(config.privateKey)
@@ -58,6 +66,13 @@ export function createEvmServerWallet(config: EvmServerWalletConfig): WalletAdap
5866
},
5967
},
6068

69+
/**
70+
* No-op connect — server wallet is always connected via private key.
71+
*
72+
* @precondition none
73+
* @postcondition returns WalletConnection with the private key account
74+
* @postcondition result.accounts.length === 1
75+
*/
6176
async connect(_options?: ConnectOptions): Promise<WalletConnection> {
6277
return {
6378
accounts: [account.address],
@@ -66,7 +81,12 @@ export function createEvmServerWallet(config: EvmServerWalletConfig): WalletAdap
6681
}
6782
},
6883

69-
// Server wallet is always connected — reconnect() always returns a connection, never null.
84+
/**
85+
* Always returns a connection — server wallet is always connected.
86+
*
87+
* @precondition none
88+
* @postcondition always returns WalletConnection (never null)
89+
*/
7090
async reconnect(): Promise<WalletConnection | null> {
7191
return {
7292
accounts: [account.address],
@@ -75,10 +95,24 @@ export function createEvmServerWallet(config: EvmServerWalletConfig): WalletAdap
7595
}
7696
},
7797

98+
/**
99+
* No-op — private key wallet is always connected and cannot be disconnected.
100+
*
101+
* @precondition none
102+
* @postcondition getStatus().connected remains true
103+
*/
78104
async disconnect(): Promise<void> {
79105
// no-op: private key wallet is always connected
80106
},
81107

108+
/**
109+
* Returns wallet status — always connected for server wallets.
110+
*
111+
* @precondition none
112+
* @postcondition connected === true
113+
* @postcondition activeAccount === the private key's derived address
114+
* @invariant status never changes for a private key wallet
115+
*/
82116
getStatus(): WalletStatus {
83117
return {
84118
connected: true,
@@ -88,6 +122,13 @@ export function createEvmServerWallet(config: EvmServerWalletConfig): WalletAdap
88122
}
89123
},
90124

125+
/**
126+
* Emits current status immediately; no further changes occur for a server wallet.
127+
*
128+
* @precondition none
129+
* @postcondition listener fires once with current (always-connected) status
130+
* @returns unsubscribe function (no-op — status never changes)
131+
*/
91132
onStatusChange(listener: (status: WalletStatus) => void): () => void {
92133
// Emit current status immediately so callers receive initial state without polling.
93134
listener({
@@ -101,6 +142,12 @@ export function createEvmServerWallet(config: EvmServerWalletConfig): WalletAdap
101142
}
102143
},
103144

145+
/**
146+
* Signs an arbitrary message with the server wallet's private key.
147+
*
148+
* @precondition none (server wallet is always connected)
149+
* @postcondition result.address matches the private key's derived address
150+
*/
104151
async signMessage(input: SignMessageInput): Promise<SignatureResult> {
105152
const message = input.message instanceof Uint8Array ? { raw: input.message } : input.message
106153
const signature = await walletClient.signMessage({ message } as Parameters<
@@ -109,6 +156,12 @@ export function createEvmServerWallet(config: EvmServerWalletConfig): WalletAdap
109156
return { signature, address: account.address }
110157
},
111158

159+
/**
160+
* Signs EIP-712 typed data with the server wallet's private key.
161+
*
162+
* @precondition none (server wallet is always connected)
163+
* @postcondition result.address matches the private key's derived address
164+
*/
112165
async signTypedData(input: SignTypedDataInput): Promise<SignatureResult> {
113166
const signature = await walletClient.signTypedData({
114167
domain: input.domain,
@@ -119,10 +172,22 @@ export function createEvmServerWallet(config: EvmServerWalletConfig): WalletAdap
119172
return { signature, address: account.address }
120173
},
121174

175+
/**
176+
* Returns the viem WalletClient — always available for server wallets.
177+
*
178+
* @precondition none
179+
* @postcondition always returns the WalletClient (never null)
180+
*/
122181
async getSigner(): Promise<ChainSigner | null> {
123182
return walletClient
124183
},
125184

185+
/**
186+
* Always throws — server wallets are bound to a single chain.
187+
*
188+
* @precondition none
189+
* @throws {CapabilityNotSupportedError} always (switchChain not supported)
190+
*/
126191
async switchChain(_chainId: string | number): Promise<void> {
127192
throw new CapabilityNotSupportedError('switchChain')
128193
},

src/sdk/core/evm/transaction.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { mainnet } from 'viem/chains'
33
import { beforeEach, describe, expect, it, vi } from 'vitest'
44

55
import type { TransactionRef } from '../adapters/transaction'
6-
import { InsufficientFundsError, InvalidSignerError } from '../errors'
6+
import { ChainNotSupportedError, InsufficientFundsError, InvalidSignerError } from '../errors'
77
import { createEvmTransactionAdapter } from './transaction'
88
import type { EvmContractCall, EvmRawTransaction } from './types'
99

@@ -192,6 +192,25 @@ describe('createEvmTransactionAdapter', () => {
192192
).rejects.toThrow(InvalidSignerError)
193193
})
194194

195+
it('throws ChainNotSupportedError when execute targets an unsupported chain', async () => {
196+
const adapter = createEvmTransactionAdapter({
197+
chains: [mainnet],
198+
transports: { [mainnet.id]: http() },
199+
})
200+
201+
const mockWalletClient = {
202+
sendTransaction: vi.fn().mockResolvedValue('0xhash'),
203+
writeContract: vi.fn(),
204+
}
205+
206+
await expect(
207+
adapter.execute(
208+
{ chainId: 999999, payload: { to: '0xabc' as `0x${string}` } as EvmRawTransaction },
209+
mockWalletClient as never,
210+
),
211+
).rejects.toThrow(ChainNotSupportedError)
212+
})
213+
195214
it('returns TransactionRef with hash for EvmRawTransaction', async () => {
196215
const adapter = createEvmTransactionAdapter({
197216
chains: [mainnet],

src/sdk/core/evm/transaction.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type {
1616
TransactionResult,
1717
} from '../adapters/transaction'
1818
import type { ChainSigner } from '../adapters/wallet'
19-
import { InsufficientFundsError, InvalidSignerError } from '../errors'
19+
import { ChainNotSupportedError, InsufficientFundsError, InvalidSignerError } from '../errors'
2020
import { fromViemChain } from './chains'
2121
import type { EvmContractCall, EvmRawTransaction, EvmTransactionPayload } from './types'
2222

@@ -41,6 +41,12 @@ function isWalletClient(signer: unknown): signer is WalletClient {
4141

4242
/**
4343
* Creates an EVM TransactionAdapter backed by viem's PublicClient (reads) and WalletClient (writes).
44+
*
45+
* @precondition config.chains entries must have corresponding transports
46+
* @postcondition returned adapter.chainType === 'evm'
47+
* @postcondition returned adapter.supportedChains matches config.chains (mapped via fromViemChain)
48+
* @invariant adapter.chainType never changes after construction
49+
* @invariant adapter.supportedChains never changes after construction
4450
*/
4551
export function createEvmTransactionAdapter(
4652
config: EvmTransactionConfig = { chains: [], transports: {} },
@@ -72,6 +78,14 @@ export function createEvmTransactionAdapter(
7278
confirmationModel: 'blockConfirmations',
7379
},
7480

81+
/**
82+
* Estimates gas and validates readiness for the given transaction params.
83+
*
84+
* @precondition params.chainId is in supportedChains
85+
* @postcondition if ready === true -> execute() can be called with these params
86+
* @postcondition if ready === false -> reason explains why (human-readable)
87+
* @throws {InsufficientFundsError} if balance too low for gas estimation
88+
*/
7589
async prepare(params: TransactionParams): Promise<PrepareResult> {
7690
const numericId =
7791
typeof params.chainId === 'string' ? Number.parseInt(params.chainId, 10) : params.chainId
@@ -138,10 +152,25 @@ export function createEvmTransactionAdapter(
138152
}
139153
},
140154

155+
/**
156+
* Submits the transaction to the network via the provided WalletClient signer.
157+
*
158+
* @precondition signer is a valid WalletClient for this adapter's chainType
159+
* @precondition params.chainId is in supportedChains
160+
* @postcondition returns TransactionRef with a unique id (tx hash)
161+
* @postcondition the transaction has been submitted to the network (not yet confirmed)
162+
* @throws {InvalidSignerError} if signer is not a WalletClient
163+
* @throws {ChainNotSupportedError} if chainId not in supportedChains
164+
*/
141165
async execute(params: TransactionParams, signer: ChainSigner): Promise<TransactionRef> {
142166
if (!isWalletClient(signer)) {
143167
throw new InvalidSignerError('WalletClient')
144168
}
169+
const numericId =
170+
typeof params.chainId === 'string' ? Number.parseInt(params.chainId, 10) : params.chainId
171+
if (!publicClients.has(numericId)) {
172+
throw new ChainNotSupportedError(params.chainId)
173+
}
145174

146175
const payload = params.payload as EvmTransactionPayload
147176

@@ -171,6 +200,14 @@ export function createEvmTransactionAdapter(
171200
return { chainType: 'evm', id: hash as string, chainId: params.chainId }
172201
},
173202

203+
/**
204+
* Waits for the transaction to be confirmed or times out.
205+
*
206+
* @precondition ref was returned by a previous execute() call on this adapter
207+
* @postcondition result.status is 'success', 'reverted', or 'timeout'
208+
* @postcondition if 'success' -> result.receipt contains a viem TransactionReceipt
209+
* @throws never (timeout returns TransactionResult with status: 'timeout')
210+
*/
174211
async confirm(ref: TransactionRef, options?: ConfirmOptions): Promise<TransactionResult> {
175212
const publicClient = getPublicClient(ref.chainId)
176213
const hash = ref.id as Hex

src/sdk/core/evm/wallet.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,9 +292,16 @@ describe('createEvmWalletAdapter — unit tests', () => {
292292

293293
describe('switchChain()', () => {
294294
it('throws ChainNotSupportedError for unsupported chainId', async () => {
295+
vi.mocked(getAccount).mockReturnValue(makeConnectedAccount())
295296
const { adapter } = makeAdapter()
296297
await expect(adapter.switchChain(999999)).rejects.toThrow(ChainNotSupportedError)
297298
})
299+
300+
it('throws WalletNotConnectedError when switchChain is called while disconnected', async () => {
301+
vi.mocked(getAccount).mockReturnValue(makeDisconnectedAccount())
302+
const { adapter } = makeAdapter()
303+
await expect(adapter.switchChain(mainnet.id)).rejects.toThrow(WalletNotConnectedError)
304+
})
298305
})
299306

300307
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)