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
2 changes: 2 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ function CabinetPage(props: {
session={props.session}
machineToken={props.machineToken}
onMachineTokenChange={props.setMachineToken}
{...(props.userWallet ? { userWallet: props.userWallet } : {})}
>
<header className="app-header hero-section cabinet-header">
<Hero>
Expand Down Expand Up @@ -508,6 +509,7 @@ export function App(props: AppProps = {}) {
session={session}
machineToken={machineToken}
onMachineTokenChange={setMachineToken}
{...(props.userWallet ? { userWallet: props.userWallet } : {})}
>
<details className="intent-context technical-details">
<summary>Open a request by identifier (advanced)</summary>
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/auth/privy-session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export function usePrivyUserWallet(): UserWalletSession {
const { user } = usePrivy();
const { ready: walletsReady, wallets } = useWallets();
const { wallet: activeWallet, connect: connectWallet } = useActiveWallet();
const [connectedWalletAddress, setConnectedWalletAddress] = useState<string | null>(null);
const explicitlyConnectedWallet = useRef<{
readonly subject: string | null;
readonly wallet: EthereumWallet;
Expand All @@ -125,6 +126,7 @@ export function usePrivyUserWallet(): UserWalletSession {
const result = await connectWallet({ reset: true });
if (result.wallet?.type !== 'ethereum') return undefined;
explicitlyConnectedWallet.current = { subject, wallet: result.wallet };
setConnectedWalletAddress(result.wallet.address);
return result.wallet;
}

Expand Down Expand Up @@ -171,7 +173,7 @@ export function usePrivyUserWallet(): UserWalletSession {
address:
selectedWallet?.address ??
(explicitlyConnectedWallet.current?.subject === subject
? explicitlyConnectedWallet.current.wallet.address
? (connectedWalletAddress ?? explicitlyConnectedWallet.current.wallet.address)
: null),
connect,
sendTransfer,
Expand Down
48 changes: 45 additions & 3 deletions apps/web/src/components/JobWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,33 @@ function explorerHref(transactionHash: string | undefined): string | undefined {
: undefined;
}

const USER_WALLET_PAYMENT_CHECK_DELAY_MS = 500;
const USER_WALLET_PAYMENT_CHECK_ATTEMPTS = 30;

function waitForPaymentCheck(): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, USER_WALLET_PAYMENT_CHECK_DELAY_MS);
});
}

async function resolveUserWalletPayment(
client: JobApiClient,
jobId: string,
transactionHash: string,
initial: JobView,
): Promise<JobView> {
let latest = initial;
for (
let attempt = 0;
attempt < USER_WALLET_PAYMENT_CHECK_ATTEMPTS && latest.payment_state === 'UNKNOWN';
attempt += 1
) {
await waitForPaymentCheck();
latest = await client.submitUserWalletPayment(jobId, transactionHash);
}
return latest;
}

export function SupplierQuotePanel({
quote,
heading = 'Supplier quote',
Expand Down Expand Up @@ -221,13 +248,21 @@ export function JobWorkspace(props: {
const transactionHash = await userWallet.sendTransfer(job.user_payment);
submittedHash = transactionHash;
setPaymentHash(transactionHash);
const updated = await props.client.submitUserWalletPayment(job.job_id, transactionHash);
const initial = await props.client.submitUserWalletPayment(job.job_id, transactionHash);
setApprovedJob(initial);
setPaymentChecking(initial.payment_state === 'UNKNOWN');
const updated = await resolveUserWalletPayment(
props.client,
job.job_id,
transactionHash,
initial,
);
setApprovedJob(updated);
setNotice(
updated.payment_state === 'COMMITTED'
? 'Payment confirmed from your connected wallet. Supplier delivery can now continue.'
: updated.payment_state === 'UNKNOWN'
? 'Transaction recorded but not final. Check the same transaction later; do not pay again.'
? 'Transaction recorded but not final. Automatic checks ended; use the same transaction check if needed. Do not pay again.'
: `Payment state: ${updated.payment_state}.`,
);
} catch {
Expand All @@ -239,6 +274,7 @@ export function JobWorkspace(props: {
: 'The payment was not prepared. Keep the same task key if you need to inspect it.',
);
} finally {
setPaymentChecking(false);
setStarting(false);
}
}
Expand All @@ -247,7 +283,13 @@ export function JobWorkspace(props: {
if (!approvedJob || !paymentHash) return;
setPaymentChecking(true);
try {
const updated = await props.client.submitUserWalletPayment(approvedJob.job_id, paymentHash);
const initial = await props.client.submitUserWalletPayment(approvedJob.job_id, paymentHash);
const updated = await resolveUserWalletPayment(
props.client,
approvedJob.job_id,
paymentHash,
initial,
);
setApprovedJob(updated);
setNotice(
updated.payment_state === 'COMMITTED'
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/components/LoginGate.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useState, type ReactNode } from 'react';
import type { OperatorSession } from '../auth/session.js';
import type { OperatorSession, UserWalletSession } from '../auth/session.js';
import { maskIdentifier } from './workspace-copy.js';

export interface LoginGateProps {
readonly session: OperatorSession;
readonly machineToken: string;
readonly onMachineTokenChange: (value: string) => void;
readonly showMachineToken?: boolean;
readonly userWallet?: UserWalletSession;
readonly children: ReactNode;
}

Expand Down Expand Up @@ -87,6 +88,12 @@ export function LoginGate(props: LoginGateProps) {
<code className="operator-did" title="Technical session identifier">
{maskIdentifier(props.session.subject, 8)}
</code>
<div className="session-wallet-address">
<span>Wallet address</span>
<code title="Wallet address used for user-wallet payments">
{props.userWallet?.address ?? 'No wallet connected'}
</code>
</div>
<button
type="button"
className="btn-copy"
Expand Down
23 changes: 18 additions & 5 deletions apps/web/test/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,12 +234,22 @@ describe('JobWorkspace payment inputs', () => {
payment_state: 'COMMITTED' as const,
user_payment: { ...payment, transaction_hash: paymentHash },
};
const unknownJob = {
...preparedJob,
payment_state: 'UNKNOWN' as const,
user_payment: { ...payment, transaction_hash: paymentHash },
};
const sendTransfer = vi.fn(async () => paymentHash);
let submissionAttempts = 0;
const submitUserWalletPayment = vi.fn(async () => {
submissionAttempts += 1;
return submissionAttempts === 1 ? unknownJob : committedJob;
});
const client = {
quote: vi.fn(async () => supplier),
start: vi.fn(),
prepareUserWalletJob: vi.fn(async () => preparedJob),
submitUserWalletPayment: vi.fn(async () => committedJob),
submitUserWalletPayment,
};

render(
Expand Down Expand Up @@ -267,9 +277,13 @@ describe('JobWorkspace payment inputs', () => {
}),
);
expect(sendTransfer).toHaveBeenCalledWith(payment);
expect(client.submitUserWalletPayment).toHaveBeenCalledWith(
preparedJob.job_id,
paymentHash,
expect(client.submitUserWalletPayment).toHaveBeenCalledWith(preparedJob.job_id, paymentHash);
await waitFor(
() => {
expect(submissionAttempts).toBe(2);
expect(screen.getByText(/Payment confirmed from your connected wallet/u)).toBeTruthy();
},
{ timeout: 3000 },
);
expect(client.start).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -440,5 +454,4 @@ describe('JobWorkspace payment inputs', () => {
);
expect(client.list).toHaveBeenCalledTimes(2);
});

});
7 changes: 7 additions & 0 deletions apps/web/test/login-gate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,25 @@ describe('LoginGate', () => {
});

it('shows the console and masks the session identifier when signed in', () => {
const walletAddress = '0x1111111111111111111111111111111111111111';
render(
<LoginGate
session={signedInSession('did:privy:abc123')}
machineToken=""
onMachineTokenChange={() => {}}
userWallet={{
address: walletAddress,
connect: async () => walletAddress,
sendTransfer: async () => `0x${'a'.repeat(64)}`,
}}
>
<p>{CONSOLE_TEXT}</p>
</LoginGate>,
);
expect(screen.getByText(CONSOLE_TEXT)).toBeTruthy();
expect(screen.getByText('PRIVY CONNECTED')).toBeTruthy();
expect(screen.getByText('Workspace session active')).toBeTruthy();
expect(screen.getByText(walletAddress)).toBeTruthy();
expect(screen.queryByText('did:privy:abc123')).toBeNull();
});

Expand Down
Loading