Skip to content

Commit 4e1bf49

Browse files
committed
improved tests, added fee stimation web
1 parent 865f4b5 commit 4e1bf49

16 files changed

+704
-361
lines changed

Cargo.lock

Lines changed: 0 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/contracts/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ simplicity-contracts = { workspace = true }
4040
anyhow = "1"
4141
cargo-husky = { workspace = true }
4242
lwk_common = "0.15.0"
43-
lwk_simplicity = { git = "https://github.com/KyrylR/lwk", rev = "fd33ba1", features = ["wallet_abi_test_utils"] }
43+
lwk_simplicity = { path = "../../../lwk/lwk_simplicity", features = ["wallet_abi_test_utils"] }
4444
lwk_wollet = { version = "0.15.0", default-features = false, features = ["electrum", "esplora"] }
4545
minreq = "2.14.1"
4646
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }

crates/contracts/tests/support/wallet_abi_lending_support.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,7 @@ impl LendingScenario {
471471
],
472472
))
473473
.await?;
474+
harness.mine_and_sync(1).await?;
474475
let principal_lend_utxo = harness.find_output(&split_tx, |tx_out| {
475476
tx_out.script_pubkey == *harness.wallet_script_25()
476477
&& tx_out.asset.explicit() == Some(principal_asset_id)

web/src/App.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ function shortId(value: string, head = 8, tail = 4): string {
3333
}
3434

3535
function WalletSessionMenu() {
36-
const { signingXOnlyPubkey, network, disconnect } = useWalletAbiSession()
36+
const { signingXOnlyPubkey, network, reconnect, disconnect } = useWalletAbiSession()
3737
const [open, setOpen] = useState(false)
3838
const ref = useRef<HTMLDivElement | null>(null)
3939

@@ -66,14 +66,25 @@ function WalletSessionMenu() {
6666
</p>
6767
<p className="mt-3 text-sm text-neutral-600">Network: {network ?? 'unknown'}</p>
6868
<p className="mt-2 break-all font-mono text-xs text-neutral-800">{signingXOnlyPubkey}</p>
69+
<button
70+
type="button"
71+
onClick={(event: ReactMouseEvent<HTMLButtonElement>) => {
72+
event.preventDefault()
73+
setOpen(false)
74+
void reconnect()
75+
}}
76+
className="mt-4 w-full rounded-full bg-neutral-950 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800"
77+
>
78+
Reconnect
79+
</button>
6980
<button
7081
type="button"
7182
onClick={(event: ReactMouseEvent<HTMLButtonElement>) => {
7283
event.preventDefault()
7384
setOpen(false)
7485
void disconnect()
7586
}}
76-
className="mt-4 w-full rounded-full border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-800 hover:bg-neutral-50"
87+
className="mt-3 w-full rounded-full border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-800 hover:bg-neutral-50"
7788
>
7889
Disconnect
7990
</button>

web/src/api/esplora.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { DEFAULT_FEE_RATE_SAT_KVB, EsploraClient, resolveWalletFeeRateSatKvb } from './esplora'
3+
4+
describe('Esplora fee estimates', () => {
5+
const fetchMock = vi.fn()
6+
7+
beforeEach(() => {
8+
vi.stubGlobal('fetch', fetchMock)
9+
})
10+
11+
afterEach(() => {
12+
fetchMock.mockReset()
13+
vi.unstubAllGlobals()
14+
})
15+
16+
it('uses the exact target when Esplora returns it', async () => {
17+
fetchMock.mockResolvedValueOnce(
18+
new Response(JSON.stringify({ 1: 0.25, 6: 0.1 }), { status: 200 })
19+
)
20+
21+
const client = new EsploraClient('https://esplora.example')
22+
23+
await expect(client.getFeeRateSatKvb(1)).resolves.toBe(250)
24+
expect(fetchMock).toHaveBeenCalledWith('https://esplora.example/fee-estimates', {
25+
signal: expect.any(AbortSignal),
26+
})
27+
})
28+
29+
it('falls back to the next higher target when the exact one is missing', async () => {
30+
fetchMock.mockResolvedValueOnce(
31+
new Response(JSON.stringify({ 2: 0.5, 144: 0.1 }), { status: 200 })
32+
)
33+
34+
const client = new EsploraClient('https://esplora.example')
35+
36+
await expect(client.getFeeRateSatKvb(1)).resolves.toBe(500)
37+
})
38+
39+
it('uses any available numeric target when no higher target exists', async () => {
40+
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ 144: 0.2 }), { status: 200 }))
41+
42+
const client = new EsploraClient('https://esplora.example')
43+
44+
await expect(client.getFeeRateSatKvb(1008)).resolves.toBe(200)
45+
})
46+
47+
it('returns the hardcoded fallback when fee estimates are unusable', async () => {
48+
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ foo: 'bar' }), { status: 200 }))
49+
50+
const client = new EsploraClient('https://esplora.example')
51+
52+
await expect(resolveWalletFeeRateSatKvb(client)).resolves.toBe(DEFAULT_FEE_RATE_SAT_KVB)
53+
})
54+
55+
it('returns the hardcoded fallback when the fee request fails', async () => {
56+
fetchMock.mockRejectedValueOnce(new Error('network down'))
57+
58+
const client = new EsploraClient('https://esplora.example')
59+
60+
await expect(resolveWalletFeeRateSatKvb(client)).resolves.toBe(DEFAULT_FEE_RATE_SAT_KVB)
61+
})
62+
})

web/src/api/esplora.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { getEsploraApiBaseUrl, getEsploraExplorerBaseUrl } from '../config/runti
1010

1111
/** Default request timeout in milliseconds. */
1212
export const DEFAULT_TIMEOUT_MS = 30_000
13+
export const DEFAULT_ESPLORA_FEE_TARGET_BLOCKS = 1
14+
export const DEFAULT_FEE_RATE_SAT_KVB = 100
1315

1416
function normalizeBaseUrl(value?: string): string | undefined {
1517
if (typeof value !== 'string' || !value.trim()) {
@@ -30,6 +32,41 @@ function getExplorerBaseUrl(apiBaseUrl: string): string {
3032
return apiBaseUrl
3133
}
3234

35+
function isValidFeeEstimateEntry(target: number, rate: number): boolean {
36+
return Number.isInteger(target) && target > 0 && Number.isFinite(rate) && rate > 0
37+
}
38+
39+
function parseFeeEstimateEntries(
40+
estimates: Record<string, number>
41+
): Array<readonly [number, number]> {
42+
return Object.entries(estimates)
43+
.map(([target, rate]) => [Number(target), rate] as const)
44+
.filter(([target, rate]) => isValidFeeEstimateEntry(target, rate))
45+
.sort(([leftTarget], [rightTarget]) => leftTarget - rightTarget)
46+
}
47+
48+
export function selectFeeRateSatVb(
49+
estimates: Record<string, number>,
50+
targetBlocks: number
51+
): number {
52+
const entries = parseFeeEstimateEntries(estimates)
53+
if (entries.length === 0) {
54+
throw new EsploraApiError('No fee estimates available')
55+
}
56+
57+
const exactEntry = entries.find(([target]) => target === targetBlocks)
58+
if (exactEntry) {
59+
return exactEntry[1]
60+
}
61+
62+
const higherTargetEntry = entries.find(([target]) => target > targetBlocks)
63+
if (higherTargetEntry) {
64+
return higherTargetEntry[1]
65+
}
66+
67+
return entries[0][1]
68+
}
69+
3370
export class EsploraApiError extends Error {
3471
readonly status: number | undefined
3572
readonly body: string | undefined
@@ -162,6 +199,39 @@ export class EsploraClient {
162199
return height
163200
}
164201

202+
/** GET /fee-estimates — confirmation target to fee rate map in sat/vB. */
203+
async getFeeEstimates(): Promise<Record<string, number>> {
204+
const body = await this.get('/fee-estimates')
205+
try {
206+
const raw = JSON.parse(body) as unknown
207+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
208+
throw new EsploraApiError('Expected fee estimate object')
209+
}
210+
211+
return Object.fromEntries(
212+
Object.entries(raw).filter(
213+
([target, rate]) =>
214+
Number.isInteger(Number(target)) &&
215+
Number(target) > 0 &&
216+
typeof rate === 'number' &&
217+
Number.isFinite(rate) &&
218+
rate > 0
219+
)
220+
)
221+
} catch (e) {
222+
if (e instanceof EsploraApiError) throw e
223+
throw new EsploraApiError(
224+
`Failed to parse fee estimates: ${e instanceof Error ? e.message : String(e)}`
225+
)
226+
}
227+
}
228+
229+
/** Resolve a fee rate for the target in sats/kvB. */
230+
async getFeeRateSatKvb(targetBlocks: number): Promise<number> {
231+
const feeRateSatVb = selectFeeRateSatVb(await this.getFeeEstimates(), targetBlocks)
232+
return feeRateSatVb * 1000
233+
}
234+
165235
/** Block hash at a given height. */
166236
async getBlockHashAtHeight(blockHeight: number): Promise<string> {
167237
const body = await this.get(`/block-height/${blockHeight}`)
@@ -383,6 +453,18 @@ export interface EsploraOutspend {
383453
[key: string]: unknown
384454
}
385455

456+
export async function resolveWalletFeeRateSatKvb(
457+
esplora: Pick<EsploraClient, 'getFeeRateSatKvb'>,
458+
targetBlocks: number = DEFAULT_ESPLORA_FEE_TARGET_BLOCKS,
459+
fallbackFeeRateSatKvb: number = DEFAULT_FEE_RATE_SAT_KVB
460+
): Promise<number> {
461+
try {
462+
return await esplora.getFeeRateSatKvb(targetBlocks)
463+
} catch {
464+
return fallbackFeeRateSatKvb
465+
}
466+
}
467+
386468
/**
387469
* Hash script_pubkey (hex) to 32-byte script hash (SHA256).
388470
* Matches Rust hash_script; use this for PreLockArguments script hashes.

0 commit comments

Comments
 (0)