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
10 changes: 10 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,15 @@ jobs:
BUNDLEWATCH_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

# ── #740: validate every migration has a rollback script ────────────────────
validate-migration-registry:
name: Validate Migration Registry
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check migrations dir, registry and rollbacks agree
run: scripts/check_migrations_registry.sh

validate-migration-rollbacks:
name: Validate Migration Rollback Scripts
runs-on: ubuntu-latest
Expand Down Expand Up @@ -1510,6 +1519,7 @@ jobs:
- bundle-size
- documentation-sync
- openapi-drift-check
- validate-migration-registry
- validate-migration-rollbacks
- rollback-idempotency
- api-criterion-benchmarks
Expand Down
25 changes: 16 additions & 9 deletions frontend/src/components/markets/PlaceBetForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import React from 'react';
import { api, ApiError } from '../../lib/api/public-client';
import { useWallet, WALLET_NOT_INSTALLED } from '../../lib/wallet/WalletProvider';
import { placeBetOnChain } from '../../lib/wallet/soroban';
import { fetchAssetBalance, fetchNativeBalance } from '../../lib/wallet/balance';
import { SUPPORTED_ASSETS, getSupportedAsset } from '../../lib/assets';
import { INSUFFICIENT_BALANCE_MESSAGE, isInsufficientBalanceError } from '../../lib/wallet/errors';
Expand Down Expand Up @@ -115,14 +116,20 @@ export const PlaceBetForm: React.FC<PlaceBetFormProps> = ({ market, onSubmitted
setInsufficientBalance(null);

try {
const result = await api.placeBet(market.id, {
wallet: wallet.address,
outcome: selectedOutcome as number,
amount,
});
setTxHash(result.tx_hash);
setTxStatus(result.status);
onSubmitted?.({ txHash: result.tx_hash, outcome: selectedOutcome as number, amount });
// The wallet signs the contract invocation; the API has no bet route.
const hash = await placeBetOnChain(
{
bettor: wallet.address,
marketId: market.id,
outcome: selectedOutcome as number,
amount,
tokenAddress: market.token_address,
},
wallet.signAndSubmit
);
setTxHash(hash);
setTxStatus('pending');
onSubmitted?.({ txHash: hash, outcome: selectedOutcome as number, amount });
} catch (err) {
if (err instanceof ApiError && isInsufficientBalanceError(err.details)) {
// Read the balance fresh at the moment of failure — never the value
Expand All @@ -136,7 +143,7 @@ export const PlaceBetForm: React.FC<PlaceBetFormProps> = ({ market, onSubmitted
setInsufficientBalance({ required: amount, current: currentBalance, code: asset.code });
} else {
const message =
err instanceof ApiError ? err.message : 'Failed to submit your bet. Please try again.';
err instanceof Error ? err.message : 'Failed to submit your bet. Please try again.';
setFormError(message);
}
} finally {
Expand Down
25 changes: 17 additions & 8 deletions frontend/src/components/markets/__tests__/PlaceBetForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,15 @@ jest.mock('../../../lib/api/public-client', () => {
...actual,
api: {
...actual.api,
placeBet: (...args: unknown[]) => mockPlaceBet(...args),
getTransactionStatus: (...args: unknown[]) => mockGetTransactionStatus(...args),
},
};
});

jest.mock('../../../lib/wallet/soroban', () => ({
placeBetOnChain: (...args: unknown[]) => mockPlaceBet(...args),
}));

const mockFetchNativeBalance = jest.fn();
const mockFetchAssetBalance = jest.fn();
jest.mock('../../../lib/wallet/balance', () => ({
Expand Down Expand Up @@ -188,7 +191,7 @@ describe('PlaceBetForm', () => {
});

it('clears a previous validation error on a new submit attempt', async () => {
mockPlaceBet.mockResolvedValue({ tx_hash: 'abc123', status: 'pending' });
mockPlaceBet.mockResolvedValue('abc123');
renderForm();

const amountInput = screen.getByRole('spinbutton');
Expand Down Expand Up @@ -217,9 +220,9 @@ describe('PlaceBetForm', () => {
setWalletConnected();
});

it('calls api.placeBet with correct arguments and shows the tx status', async () => {
it('signs and submits placeBetOnChain with correct arguments and shows the tx status', async () => {
const TX_HASH = 'abcdef1234567890';
mockPlaceBet.mockResolvedValue({ tx_hash: TX_HASH, status: 'pending' });
mockPlaceBet.mockResolvedValue(TX_HASH);
const onSubmitted = jest.fn();

renderForm(onSubmitted);
Expand All @@ -230,8 +233,14 @@ describe('PlaceBetForm', () => {

await waitFor(() => {
expect(mockPlaceBet).toHaveBeenCalledWith(
MARKET.id,
{ wallet: WALLET_ADDRESS, outcome: 0, amount: '25' }
{
bettor: WALLET_ADDRESS,
marketId: MARKET.id,
outcome: 0,
amount: '25',
tokenAddress: undefined,
},
mockWallet.signAndSubmit
);
});

Expand All @@ -243,7 +252,7 @@ describe('PlaceBetForm', () => {

it('calls onSubmitted callback with txHash, outcome, and amount', async () => {
const TX_HASH = 'deadbeef12345678';
mockPlaceBet.mockResolvedValue({ tx_hash: TX_HASH, status: 'pending' });
mockPlaceBet.mockResolvedValue(TX_HASH);
const onSubmitted = jest.fn();

renderForm(onSubmitted);
Expand All @@ -265,7 +274,7 @@ describe('PlaceBetForm', () => {
});

it('works without an onSubmitted prop (optional callback)', async () => {
mockPlaceBet.mockResolvedValue({ tx_hash: 'txhash001', status: 'pending' });
mockPlaceBet.mockResolvedValue('txhash001');

renderForm(); // no onSubmitted

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,6 @@ describe('API client paths match schema.d.ts (contract test, #3)', () => {
// new endpoint with a mistyped or un-schema'd path fails here at PR time (#1342).
const KNOWN_UNLISTED: ReadonlySet<string> = new Set([
'/health', // liveness probe, not part of the versioned API surface
'/api/v1/blockchain/markets/{market_id}/bets', // placeBet - pending openapi.yaml (#78)
'/api/v1/newsletter/subscribe',
'/api/v1/newsletter/confirm',
'/api/v1/newsletter/unsubscribe',
Expand Down
39 changes: 12 additions & 27 deletions frontend/src/lib/api/__tests__/idempotency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,13 @@ describe('idempotency key on mutating requests', () => {
return headers['Idempotency-Key'];
}

it('attaches an Idempotency-Key header to placeBet', async () => {
it('attaches an Idempotency-Key header to newsletterSubscribe', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
text: async () => JSON.stringify({ tx_hash: '0xabc', status: 'pending' }),
text: async () => JSON.stringify({ success: true, message: 'ok' }),
});

await api.placeBet(7, { wallet: 'GABC', outcome: 1, amount: '10' });
await api.newsletterSubscribe({ email: 'a@example.com' });

const call = (global.fetch as jest.Mock).mock.calls[0];
expect(keyOf(call)).toMatch(UUID_RE);
Expand All @@ -73,10 +73,10 @@ describe('idempotency key on mutating requests', () => {
// retry: succeeds
.mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify({ tx_hash: '0xabc', status: 'pending' }),
text: async () => JSON.stringify({ success: true, message: 'ok' }),
});

await api.placeBet(7, { wallet: 'GABC', outcome: 1, amount: '10' });
await api.newsletterSubscribe({ email: 'a@example.com' });

const calls = (global.fetch as jest.Mock).mock.calls;
expect(calls).toHaveLength(2);
Expand All @@ -87,31 +87,16 @@ describe('idempotency key on mutating requests', () => {
it('generates a fresh key for each new user-initiated submission', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
text: async () => JSON.stringify({ tx_hash: '0xabc', status: 'pending' }),
text: async () => JSON.stringify({ success: true, message: 'ok' }),
});

await api.placeBet(7, { wallet: 'GABC', outcome: 1, amount: '10' });
await api.placeBet(7, { wallet: 'GABC', outcome: 2, amount: '25' });
await api.newsletterSubscribe({ email: 'a@example.com' });
await api.newsletterSubscribe({ email: 'a@example.com' });

const calls = (global.fetch as jest.Mock).mock.calls;
expect(keyOf(calls[0])).not.toBe(keyOf(calls[1]));
});

it('honours a caller-supplied key (double-click guard use case)', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
text: async () => JSON.stringify({ tx_hash: '0xabc', status: 'pending' }),
});

const key = newIdempotencyKey();
await api.placeBet(7, { wallet: 'GABC', outcome: 1, amount: '10' }, { idempotencyKey: key });
await api.placeBet(7, { wallet: 'GABC', outcome: 1, amount: '10' }, { idempotencyKey: key });

const calls = (global.fetch as jest.Mock).mock.calls;
expect(keyOf(calls[0])).toBe(key);
expect(keyOf(calls[1])).toBe(key);
});

it('does not create a duplicate when a failed-then-succeeded submit is retried (deduping backend)', async () => {
// A backend that has already committed the bet on the (lost) first response
// and now deduplicates on the key: a second request with the same key
Expand All @@ -125,7 +110,7 @@ describe('idempotency key on mutating requests', () => {
if (already) {
return Promise.resolve({
ok: true,
text: async () => JSON.stringify({ tx_hash: '0xabc', status: 'pending', deduped: true }),
text: async () => JSON.stringify({ success: true, message: 'ok', deduped: true }),
});
}
committed.push({ key });
Expand All @@ -138,9 +123,9 @@ describe('idempotency key on mutating requests', () => {
});
});

const result = await api.placeBet(7, { wallet: 'GABC', outcome: 1, amount: '10' });
const result = await api.newsletterSubscribe({ email: 'a@example.com' });

expect(result).toMatchObject({ tx_hash: '0xabc' });
expect(result).toMatchObject({ success: true });
expect(committed).toHaveLength(1);
}, 10000);

Expand All @@ -153,7 +138,7 @@ describe('idempotency key on mutating requests', () => {
});

await expect(
api.placeBet(7, { wallet: 'GABC', outcome: 1, amount: '10' }),
api.newsletterSubscribe({ email: 'a@example.com' }),
).rejects.toBeInstanceOf(ApiError);
}, 10000);
});
8 changes: 0 additions & 8 deletions frontend/src/lib/api/__tests__/request-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,6 @@ describe('boundary validation in the client', () => {
expect(global.fetch).not.toHaveBeenCalled();
});

it('rejects a malformed placeBet body locally, without calling fetch', async () => {
await expect(
api.placeBet(1, { wallet: 'GABC', outcome: -3, amount: '10' }),
).rejects.toBeInstanceOf(ApiError);

expect(global.fetch).not.toHaveBeenCalled();
});

it('sends a well-formed body through to the network', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
Expand Down
34 changes: 0 additions & 34 deletions frontend/src/lib/api/public-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
newsletterSubscribeSchema,
emailRequestSchema,
gdprExportSchema,
placeBetSchema,
} from './requestSchemas';
import { reportRateLimited } from './rateLimit';
import type { paths, components } from './schema';
Expand Down Expand Up @@ -51,13 +50,6 @@ const PATHS = {
transactionStatus: "/api/v1/blockchain/tx/{tx_hash}",
} satisfies Record<string, keyof paths>;

/**
* `placeBet` is not yet part of services/api/openapi.yaml (see #78), so its
* path is kept out of the `satisfies keyof paths` check above rather than
* loosening that check for every other entry.
*/
const PLACE_BET_PATH = "/api/v1/blockchain/markets/{market_id}/bets";

// Path-parameter encoding lives in ./paths. Re-exported here so existing importers of
// `fillPath` from './public-client' keep working.
export { fillPath, fillPathParams } from './paths';
Expand Down Expand Up @@ -491,32 +483,6 @@ export const api = {
signal,
}),

/**
* Submits a bet for the connected wallet. Returns the pending on-chain tx.
*
* An idempotency key is generated per call so an automatic network retry
* can't place the bet twice (#1340). A caller that also needs to survive a
* double-click (see #13) can pass its own `idempotencyKey` and hold it
* stable until the submission succeeds; editing the amount/outcome and
* resubmitting is a new logical submission and should use a new key.
*/
placeBet: (
marketId: number | string,
body: { wallet: string; outcome: number; amount: string },
options: { idempotencyKey?: string; signal?: AbortSignal } = {}
) =>
request<{ tx_hash: string; status: string }>(
"POST",
fillPath(PLACE_BET_PATH, 'market_id', marketId),
{
body,
bodySchema: placeBetSchema,
cacheTags: [CacheTag.BLOCKCHAIN, CacheTag.MARKETS],
idempotencyKey: options.idempotencyKey ?? true,
signal: options.signal,
},
),

// Newsletter (public subscription / self-service)
newsletterSubscribe: (body: { email: string; source?: string }, signal?: AbortSignal) =>
request<{ success: boolean; message: string }>("POST", "/api/v1/newsletter/subscribe", {
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/lib/wallet/__tests__/soroban.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
isSorobanConfigured,
resolveSorobanClientConfig,
submitInvocation,
toBaseUnits,
type SorobanClientConfig,
} from '../soroban';

Expand Down Expand Up @@ -328,3 +329,11 @@ describe('invokeContract', () => {
expect(sign).not.toHaveBeenCalled();
});
});

describe('toBaseUnits', () => {
it('converts decimal amounts to 7-decimal base units', () => {
expect(toBaseUnits('10')).toBe(100000000n);
expect(toBaseUnits('1.5')).toBe(15000000n);
expect(toBaseUnits('0.0000001')).toBe(1n);
});
});
Loading