Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 89 additions & 89 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"preview": "vite preview"
},
"dependencies": {
"@utexo/rgb-sdk-web": "1.0.0-beta.10",
"@utexo/rgb-sdk-web": "1.0.0-beta.11",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.26.0",
Expand Down
8 changes: 4 additions & 4 deletions src/components/RegtestLspFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ export function RegtestLspFlow() {
const payHash = String(res.txid ?? '');
log(`pay status: ${res.status ?? 'sent'} (payment hash ${payHash})`, 'ok');

// Poll until Settled — each getLightningSendRequest poll also drives the wasm
// Poll until Settled — each getLightningSendStatus poll also drives the wasm
// node's queued RGB work (HTLC/commitment coloring), without which the HTLC
// never leaves this node. Mirrors the RN flow's sender-side settle loop.
setPhase('settle');
Expand All @@ -377,12 +377,12 @@ export function RegtestLspFlow() {
while (Date.now() < payDeadline) {
await gatewayFund(address, 0.001, 1).catch(() => {});
await sleep(3000);
payStatus = await wallet.getLightningSendRequest(payHash);
payStatus = await wallet.getLightningSendStatus(payHash);
log(` send status: ${payStatus ?? 'Pending'}`);
if (payStatus === 'Settled' || payStatus === 'Failed') break;
if (payStatus === 'Succeeded' || payStatus === 'Failed') break;
}
if (payStatus === 'Failed') throw new Error('payment Failed');
if (payStatus !== 'Settled') log('send did not settle within timeout', 'err');
if (payStatus !== 'Succeeded') log('send did not settle within timeout', 'err');
else log('payment Settled ✓', 'ok');

await showAssetBalance('sender (after)');
Expand Down
59 changes: 15 additions & 44 deletions src/components/UtexoOps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,6 @@ export function UtexoOps({ utexo, walletId, network }: Props) {
const [lnSendAssetId, setLnSendAssetId] = useState('');
const [lnSendAmount, setLnSendAmount] = useState('');
const [lnOut, setLnOut] = useState('');
const [lnPendingPsbt, setLnPendingPsbt] = useState<string | null>(null);
const [lnSignedPsbt, setLnSignedPsbt] = useState<string | null>(null);

// Validate balance
const [validateAssetId, setValidateAssetId] = useState('');
Expand Down Expand Up @@ -246,11 +244,18 @@ export function UtexoOps({ utexo, walletId, network }: Props) {
} catch (e) { setOnchainOut('Error: ' + e); addLog('onchainSend failed: ' + e, 'err'); }
}

// `getOnchainSendStatus` was removed in v3 — an on-chain send's state is the
// state of its transfer, so read it from listOnchainTransfers by invoice.
async function handleGetOnchainSendStatus() {
if (!onchainInvoice.trim()) { setOnchainOut('Enter invoice'); return; }
try {
const result = await utexo.getOnchainSendStatus(onchainInvoice.trim());
setOnchainOut('Status: ' + json(result));
const transfers = await utexo.listOnchainTransfers(onchainAssetId.trim() || undefined);
const match = transfers.filter((t) => t.invoiceString === onchainInvoice.trim());
setOnchainOut(
match.length
? 'Status: ' + json(match.map((t) => ({ status: t.status, kind: t.kind, txid: t.txid })))
: 'No transfer found for that invoice'
);
} catch (e) { setOnchainOut('Error: ' + e); }
}

Expand All @@ -275,36 +280,6 @@ export function UtexoOps({ utexo, walletId, network }: Props) {
} catch (e) { setLnOut('Error: ' + e); addLog('createLightningInvoice failed: ' + e, 'err'); }
}

async function handlePayLnBegin() {
if (!lnInvoice.trim()) { setLnOut('Enter LN invoice'); return; }
try {
addLog('payLightningInvoiceBegin...', 'info');
const psbt = await utexo.payLightningInvoiceBegin({ lnInvoice: lnInvoice.trim(), assetId: lnSendAssetId.trim() || undefined, amount: lnSendAmount ? parseInt(lnSendAmount) : undefined });
setLnPendingPsbt(psbt);
setLnSignedPsbt(null);
setLnOut('Step 1 — Unsigned PSBT:\n' + psbt);
addLog('LN pay PSBT ready', 'ok');
} catch (e) { setLnOut('Error: ' + e); addLog('payLightningInvoiceBegin failed: ' + e, 'err'); }
}

async function handlePayLnSign() {
if (!lnPendingPsbt) { setLnOut('Run Step 1 first'); return; }
addLog('Signing LN PSBT...', 'info');
const signed = await utexo.signPsbt(lnPendingPsbt);
setLnSignedPsbt(signed);
setLnOut('Step 2 — Signed PSBT:\n' + signed);
addLog('LN PSBT signed', 'ok');
}

async function handlePayLnEnd() {
if (!lnSignedPsbt) { setLnOut('Sign PSBT first'); return; }
addLog('payLightningInvoiceEnd...', 'info');
const result = await utexo.payLightningInvoiceEnd({ signedPsbt: lnSignedPsbt });
setLnPendingPsbt(null); setLnSignedPsbt(null);
setLnOut('Result:\n' + json(result));
addLog('LN pay complete', 'ok');
}

async function handlePayLnAuto() {
if (!lnInvoice.trim()) { setLnOut('Enter LN invoice'); return; }
addLog('payLightningInvoice (auto)...', 'info');
Expand All @@ -318,15 +293,15 @@ export function UtexoOps({ utexo, walletId, network }: Props) {
async function handleGetLnSendRequest() {
if (!lnInvoice.trim()) { setLnOut('Enter LN invoice'); return; }
try {
const result = await utexo.getLightningSendRequest(lnInvoice.trim());
const result = await utexo.getLightningSendStatus(lnInvoice.trim());
setLnOut('Status: ' + json(result));
} catch (e) { setLnOut('Error: ' + e); }
}

async function handleGetLnReceiveRequest() {
if (!lnInvoice.trim()) { setLnOut('Enter LN invoice'); return; }
try {
const result = await utexo.getLightningReceiveRequest(lnInvoice.trim());
const result = await utexo.getLightningReceiveStatus(lnInvoice.trim());
setLnOut('Status: ' + json(result));
} catch (e) { setLnOut('Error: ' + e); }
}
Expand Down Expand Up @@ -486,14 +461,10 @@ export function UtexoOps({ utexo, walletId, network }: Props) {
<Btn variant="secondary" onClick={handleGetLnSendRequest}>Get LN Send Status</Btn>
<Btn variant="secondary" onClick={handleGetLnReceiveRequest}>Get LN Receive Status</Btn>
</div>
<StepFlow
steps={[
{ label: '1. Begin (get PSBT)', onClick: handlePayLnBegin },
{ label: '2. Sign PSBT', variant: 'warning', onClick: handlePayLnSign },
{ label: '3. Broadcast', variant: 'accent', onClick: handlePayLnEnd },
]}
auto={{ label: 'Pay LN Invoice (auto)', onClick: handlePayLnAuto }}
/>
{/* v3: paying an LN invoice is atomic — the begin/sign/end trio is gone. */}
<div className="flex gap-2 mb-4 flex-wrap">
<Btn variant="accent" onClick={handlePayLnAuto}>Pay LN Invoice</Btn>
</div>
<OutputBox value={lnOut} />
</Section>

Expand Down
75 changes: 70 additions & 5 deletions src/components/apay/RegularChannelFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,26 @@ export function RegularChannelFlow() {
<Btn variant="primary" onClick={flow.runCloseChannel} disabled={!flow.canRepro}>
{flow.closeRunning ? 'Closing…' : 'Close channel & settle on-chain'}
</Btn>
<Btn variant="danger" onClick={flow.runWalletFundedOpen} disabled={!flow.canFund}>
{flow.fundingRunning
? 'Funding…'
: `Wallet-funded open (${flow.fundWarm ? 'warm' : 'COLD'})`}
</Btn>
</div>
<p className="text-xs text-[#8b949e] mb-3">
<b>Wallet-funded open</b> inverts the topology: the <i>wasm</i> node opens a channel to{' '}
<code>regular_web</code> (the daemon without <code>--enable-virtual-channels-v0</code>, the
only peer that accepts a browser-initiated open) and the app funds it itself via
buildLightningFundingTx → submitFundingTransaction. Known to stall at «pending awaiting
funding lock-in». When the funding tx never reaches the indexer, the run POSTs the identical
hex to esplora directly — if esplora accepts it the tx was always valid and our broadcast
path is the bug; if esplora rejects it, the tx itself is wrong.
{' '}
<b>COLD vs warm is the experiment:</b> clicked first in a fresh tab it bootstraps its own
wallet (create → fund → sync, no createUtxos, no payments) and opens immediately; clicked
after the main flow the BDK view is warm. If cold fails and warm passes, stale-view input
selection (§6.0l) is confirmed.
</p>
<p className="text-xs text-[#8b949e] mb-3">
Keysend tests (channel_issue.md), available after the flow completes — same channel, same
amounts ({KEYSEND_REPRO_ASSET_AMOUNT} RGB), no invoice. wasm → hub settles normally;
Expand Down Expand Up @@ -177,22 +196,68 @@ export function RegularChannelFlow() {
style={
flow.closeOutcome.kind === 'settled'
? { borderColor: '#3fb950', color: '#3fb950', backgroundColor: '#3fb95010' }
: flow.closeOutcome.kind === 'partial'
? { borderColor: '#d29922', color: '#d29922', backgroundColor: '#d2992210' }
: { borderColor: '#f85149', color: '#f85149', backgroundColor: '#f8514910' }
: { borderColor: '#f85149', color: '#f85149', backgroundColor: '#f8514910' }
}
>
<div className="font-bold mb-1">
{flow.closeOutcome.kind === 'settled'
? '✓ Channel closed — split settled on-chain'
? '✓ Channel closed — split settled on-chain (both sides)'
: flow.closeOutcome.kind === 'partial'
? '⚠ Closed — hub settled on-chain; wasm sweep not implemented'
? '✗ Only the hub settled — the wasm post-close sweep did not land'
: 'Close / on-chain settle incomplete'}
</div>
{flow.closeOutcome.detail}
</div>
)}

{flow.funding && (
<InfoCard
title="Wallet-funded open (wasm → regular_web)"
accent="#d29922"
rows={[
['temporary channel id', short(flow.funding.temporaryChannelId, 32)],
['capacity', `${flow.funding.channelValueSat} sat`],
['funding txid', flow.funding.txid],
[
'SDK broadcast',
flow.funding.sdkBroadcast === null
? 'checking…'
: flow.funding.sdkBroadcast
? 'indexer saw it ✓'
: 'indexer never saw it ✗',
],
...(flow.funding.probe
? [['esplora POST /tx', flow.funding.probe] as [string, string]]
: []),
]}
/>
)}
{flow.fundingOutcome && (
<div
className="border rounded-lg p-4 mb-3 text-sm"
style={
flow.fundingOutcome.kind === 'ready'
? { borderColor: '#3fb950', color: '#3fb950', backgroundColor: '#3fb95010' }
: flow.fundingOutcome.kind === 'inconclusive'
? { borderColor: '#8b949e', color: '#8b949e', backgroundColor: '#8b949e10' }
: { borderColor: '#f85149', color: '#f85149', backgroundColor: '#f8514910' }
}
>
<div className="font-bold mb-1">
{flow.fundingOutcome.kind === 'ready'
? '✓ Wallet-funded channel ready'
: flow.fundingOutcome.kind === 'broadcast_broken'
? '✗ SDK broadcast path is the bug (tx was valid)'
: flow.fundingOutcome.kind === 'tx_invalid'
? '✗ Funding transaction itself is invalid'
: flow.fundingOutcome.kind === 'stalled'
? '✗ Tx published, channel never locked in'
: 'Wallet-funded open inconclusive'}
</div>
{flow.fundingOutcome.detail}
</div>
)}

{flow.verdict && (
<div
className="border rounded-lg p-4 mb-3 text-sm"
Expand Down
65 changes: 65 additions & 0 deletions src/components/apay/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ export const FAUCET_API =
// Faucet RLN LDK peer port — the wasm node dials it through the gateway ws relay.
export const FAUCET_LDK_PORT = Number(env.VITE_FAUCET_LDK_PORT ?? 9748);

// ── regular_web: the third daemon, started WITHOUT --enable-virtual-channels-v0
// (start-lsp-web.sh, VIRTUAL_CHANNELS=0). A node with that flag rejects a
// wasm-initiated open with `unsupported_scid_alias`, so this is the only peer
// the browser can open a channel *to* (MIGRATION-PLAN-v3 §6.0r/§6.0s).
// No REST base here on purpose: the gateway proxies only the faucet's API
// (/dev/regular-rln → :3108). Readiness is read from the wasm side via
// listChannels, so the pubkey and the peer port are all this flow needs.
export const REGULAR_PUBKEY = env.VITE_REGULAR_PEER_PUBKEY ?? '';
export const REGULAR_LDK_PORT = Number(env.VITE_REGULAR_LDK_PORT ?? 9750);

/** Same-origin esplora (vite proxy → CFG.indexer); esplora sends no CORS headers. */
export const INDEXER_PROXY = '/indexer';

export const BC_NAME = 'utexo-apay-flow';

export const CART_ITEM = '1× RGB Token (UTST)';
Expand Down Expand Up @@ -54,6 +67,17 @@ export const REGULAR_PAY_MSAT = 3_000_000;
/** Hub-initiated keysend repro (channel_issue.md) — asset amount matches the report. */
export const KEYSEND_REPRO_ASSET_AMOUNT = 30;

// ── Wallet-funded open repro (§6.0s) ────────────────────────────────────────
// Matches tests/e2e/i-funding.spec.ts in rgb-sdk-web so a demo run and a spec
// run are comparable. BTC-only: the asset leg is irrelevant to whether the
// funding tx reaches the mempool, and leaving it out removes a variable.
export const FUNDING_CAPACITY_SAT = 100_000;
export const FUNDING_FEE_RATE = 2;
/** How long to wait for the funding tx to appear in the indexer before probing. */
export const FUNDING_BROADCAST_TIMEOUT_S = 60;
/** How long to wait for channel_ready once the tx is in the mempool. */
export const FUNDING_READY_TIMEOUT_S = 300;

export type Role = 'merchant' | 'buyer';

export type Phase =
Expand All @@ -79,6 +103,7 @@ export type Phase =
| 'rc_pay'
| 'rc_payback'
| 'rc_keysend'
| 'rc_funding'
| 'done'
| 'error';

Expand All @@ -105,6 +130,7 @@ export const PHASE_LABELS: Record<Phase, string> = {
rc_pay: 'Pay →hub',
rc_payback: 'Pay ←hub',
rc_keysend: 'Keysend',
rc_funding: 'Wallet-funded open',
done: 'Done',
error: 'Error',
};
Expand Down Expand Up @@ -167,6 +193,45 @@ export const short = (s: string, n = 24) =>
(s || '').slice(0, n) + ((s || '').length > n ? '…' : '');
export const normHash = (h: string) => (h || '').toLowerCase().replace(/^0x/, '');

/**
* Has the indexer actually seen this transaction?
*
* Must be `GET /tx/<txid>`, which 404s on an unknown txid. Do NOT use
* `/tx/<txid>/status` for presence: esplora answers that one with
* **HTTP 200 `{"confirmed":false}`** for a txid that has never existed, so an
* `r.ok` test there reports every transaction as broadcast — including one that
* was never published at all.
*/
export async function indexerTxSeen(
txid: string
): Promise<{ confirmed: boolean } | null> {
const r = await fetch(`${INDEXER_PROXY}/tx/${txid}`);
if (!r.ok) return null; // 404 — never seen, not even in the mempool
const tx = (await r.json().catch(() => null)) as {
status?: { confirmed?: boolean };
} | null;
return { confirmed: !!tx?.status?.confirmed };
}

/**
* Push a raw tx straight to esplora, bypassing the SDK entirely.
*
* This is the bisect for §6.0s: if esplora accepts the same hex the SDK failed
* to broadcast, the transaction is valid and only our broadcast path is broken;
* if esplora rejects it, the transaction itself is wrong (stale-view input
* selection — the §6.0l race) and the broadcast path is innocent.
*/
export async function indexerBroadcast(
txHex: string
): Promise<{ accepted: boolean; body: string }> {
const r = await fetch(`${INDEXER_PROXY}/tx`, {
method: 'POST',
headers: { 'content-type': 'text/plain' },
body: txHex,
});
return { accepted: r.ok, body: (await r.text().catch(() => '')).trim() };
}

export async function gatewayFund(
address: string,
amountBtc: number,
Expand Down
14 changes: 7 additions & 7 deletions src/components/apay/signet/useApaySignetFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export function useApaySignetFlow(role: Role) {
buyerSettledRef.current = false;
addLog(`buyer payment hash received: ${short(m.paymentHash)} — watching settlement`);
}
if (m.status === 'Settled') buyerSettledRef.current = true;
if (m.status === 'Succeeded') buyerSettledRef.current = true;
setOtherStatus(`buyer payment: ${m.status}`);
} else if (m.type === 'verdict' && role === 'buyer') {
setCheckout({ ok: m.ok, soft: m.soft, detail: m.detail });
Expand Down Expand Up @@ -418,10 +418,10 @@ export function useApaySignetFlow(role: Role) {

const pays = await wallet.listPayments().catch(() => []);
const mp = pays.find((p) => normHash(p.paymentHash) === normHash(hash));
const mpStatus = String(mp?.rawStatus ?? mp?.status ?? '').toLowerCase();
const mpStatus = String(mp?.status ?? '').toLowerCase();
addLog(
`watch: channel RGB ${nowRgb} (Δ${delta >= 0 ? '+' : ''}${delta}) inbound=${
mp ? `${mp.inbound ? 'inbound' : 'outbound'}/${mp.rawStatus ?? mp.status}` : 'none'
mp ? `${mp.inbound ? 'inbound' : 'outbound'}/${mp.status}` : 'none'
} buyerSettled=${buyerSettledRef.current}`
);

Expand Down Expand Up @@ -594,14 +594,14 @@ export function useApaySignetFlow(role: Role) {
while (Date.now() < settleDeadline) {
checkAbort();
await sleep(POLL_MS);
payStatus = await wallet.getLightningSendRequest(pHash);
payStatus = await wallet.getLightningSendStatus(pHash);
setSendStatus(payStatus ?? 'Pending');
post({ type: 'payment', paymentHash: pHash, status: payStatus ?? 'Pending' });
addLog(` getLightningSendRequest: ${payStatus ?? 'Pending'}`);
if (payStatus === 'Settled' || payStatus === 'Failed') break;
addLog(` getLightningSendStatus: ${payStatus ?? 'Pending'}`);
if (payStatus === 'Succeeded' || payStatus === 'Failed') break;
}
if (payStatus === 'Failed') throw new Error('buyer payment Failed during LSP settlement');
if (payStatus !== 'Settled') {
if (payStatus !== 'Succeeded') {
throw new Error('Timeout — payment did not settle; ensure the Merchant window stays open.');
}

Expand Down
Loading
Loading