Skip to content

Commit 98d6c44

Browse files
authored
Merge pull request #552 from bridgelet-org/feat/mobile-sdk-alignment
feat(mobile): align mobile app with Bridgelet SDK API surface
2 parents 7443fe8 + 9a16d5f commit 98d6c44

9 files changed

Lines changed: 191 additions & 94 deletions

File tree

mobile/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
11
EXPO_PUBLIC_API_URL=https://api.bridgelet.io
22
EXPO_PUBLIC_ENV=development
3+
# Stellar funding account used as both fundingSource and recovery_address when
4+
# creating ephemeral accounts via POST /accounts (required by the Bridgelet SDK).
5+
EXPO_PUBLIC_FUNDING_ACCOUNT=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
6+
# Bridgelet SDK integrator API key. Sent as the X-API-Key header; required for
7+
# authenticated endpoints (POST /accounts) and applied to claim endpoints too.
8+
EXPO_PUBLIC_API_KEY=

mobile/app/src/claims/service.ts

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
1-
import {
2-
ClaimTokenError,
3-
ClaimTokenPayload,
4-
decodeClaimTokenPayload,
5-
validateClaimToken,
6-
} from "./token";
1+
import { validateClaimToken } from "./token";
2+
import { apiClient } from "../utils/apiClient";
73

84
export type ClaimLookupResult = {
95
accountId: string;
@@ -12,36 +8,47 @@ export type ClaimLookupResult = {
128
expiresAt?: string;
139
};
1410

15-
const wait = (ms: number): Promise<void> => {
16-
return new Promise((resolve) => {
17-
setTimeout(resolve, ms);
18-
});
19-
};
20-
21-
const fromPayload = (payload: ClaimTokenPayload): ClaimLookupResult => {
22-
return {
23-
accountId: payload.accountId ?? "unknown-account",
24-
amount: payload.amount ?? "0",
25-
asset: payload.asset ?? "XLM:native",
26-
expiresAt: payload.expiresAt,
27-
};
11+
type VerifyClaimResponse = {
12+
valid: boolean;
13+
accountId: string;
14+
amount: string;
15+
asset: string;
16+
expiresAt: Date;
2817
};
2918

19+
/**
20+
* Resolve a claim token against the Bridgelet SDK `POST /claims/verify`
21+
* endpoint. The server performs authoritative verification (JWT signature,
22+
* expiry, account status) and returns the claim details; the client no longer
23+
* decodes the token payload locally (the SDK signs only publicKey/type/jti,
24+
* not the account details).
25+
*/
3026
export const lookupClaimByToken = async (
3127
token: string,
3228
): Promise<ClaimLookupResult> => {
3329
validateClaimToken(token);
3430

35-
// Simulate network verification while backend endpoint is being integrated.
36-
await wait(900);
37-
3831
try {
39-
const payload = decodeClaimTokenPayload(token);
40-
return fromPayload(payload);
41-
} catch {
42-
throw new ClaimTokenError(
43-
"INVALID_TOKEN_FORMAT",
44-
"Invalid claim link format.",
45-
);
32+
const result = await apiClient<VerifyClaimResponse>("/claims/verify", {
33+
method: "POST",
34+
body: JSON.stringify({ claimToken: token }),
35+
});
36+
return {
37+
accountId: result.accountId,
38+
amount: result.amount,
39+
asset: result.asset,
40+
expiresAt: result.expiresAt ? new Date(result.expiresAt).toISOString() : undefined,
41+
};
42+
} catch (error: any) {
43+
if (error?.status === 401) {
44+
throw new Error("This claim link is invalid or has expired.");
45+
}
46+
if (error?.status === 409) {
47+
throw new Error("This payment has already been claimed.");
48+
}
49+
if (error?.status === 400) {
50+
throw new Error("This payment is not ready to be claimed yet.");
51+
}
52+
throw new Error("Unable to verify this claim token. Please try again.");
4653
}
4754
};

mobile/app/src/components/MobileSendFlow.tsx

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,21 +57,44 @@ const EXPIRY_OPTIONS = [
5757
// ─── API call ────────────────────────────────────────────────────────────────
5858

5959
async function createClaimLink(form: SendFormState): Promise<ClaimLinkResult> {
60+
// The Bridgelet SDK has no `POST /claims` endpoint. Claim links are created
61+
// by provisioning an ephemeral account via `POST /accounts`, which returns a
62+
// `claimUrl` containing the claim token. The SDK requires `fundingSource`
63+
// and `recovery_address` (Stellar public keys).
6064
const response = await fetch(
61-
`${process.env.EXPO_PUBLIC_API_URL ?? 'https://api.bridgelet.org'}/claims`,
65+
`${process.env.EXPO_PUBLIC_API_URL ?? 'https://api.bridgelet.io'}/accounts`,
6266
{
6367
method: 'POST',
64-
headers: { 'Content-Type': 'application/json' },
68+
headers: {
69+
'Content-Type': 'application/json',
70+
...(process.env.EXPO_PUBLIC_API_KEY
71+
? { 'X-API-Key': process.env.EXPO_PUBLIC_API_KEY }
72+
: {}),
73+
},
6574
body: JSON.stringify({
66-
amount: parseFloat(form.amount),
67-
asset: form.asset,
68-
note: form.recipientNote || undefined,
69-
expiresInHours: form.expiresInHours === '0' ? null : parseInt(form.expiresInHours, 10),
75+
fundingSource: process.env.EXPO_PUBLIC_FUNDING_ACCOUNT,
76+
recovery_address: process.env.EXPO_PUBLIC_FUNDING_ACCOUNT,
77+
amount: parseFloat(form.amount).toFixed(7),
78+
asset_code: form.asset === 'XLM' ? 'XLM' : form.asset,
79+
expiresIn: form.expiresInHours === '0' ? 2592000 : (parseInt(form.expiresInHours, 10) || 24) * 3600,
7080
}),
7181
},
7282
);
83+
84+
if (response.status === 401) throw new Error('Authentication failed. Check your API key configuration.');
85+
if (response.status === 429) {
86+
const retryAfter = response.headers.get('Retry-After');
87+
throw new Error(
88+
retryAfter
89+
? `Too many requests. Please wait ${retryAfter} seconds and try again.`
90+
: 'Too many requests. Please try again shortly.',
91+
);
92+
}
7393
if (!response.ok) throw new Error(`Failed to create claim: ${response.status}`);
74-
return response.json() as Promise<ClaimLinkResult>;
94+
95+
const data = await response.json();
96+
if (!data?.claimUrl) throw new Error('Claim link could not be created.');
97+
return { claimUrl: data.claimUrl, claimCode: '' };
7598
}
7699

77100
// ─── Component ────────────────────────────────────────────────────────────────

mobile/app/src/hooks/useDeepLinkClaim.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,18 +63,32 @@ function extractToken(url: string): string | null {
6363
return null;
6464
}
6565

66-
// ─── Token validation (mirrors web claim flow) ────────────────────────────────
66+
// ─── Token validation (mirrors web claim flow via the SDK) ────────────────────
6767

6868
async function validateToken(token: string): Promise<ClaimTokenStatus> {
6969
try {
70+
// The Bridgelet SDK exposes `POST /claims/verify` (no GET /claims/{token}/validate).
71+
// Mapping mirrors the web frontend (lib/claim-view.ts):
72+
// 200 -> valid, 409 -> already_claimed, 400 -> pending_payment (not claimable yet),
73+
// 401 -> expired/invalid.
7074
const response = await fetch(
71-
`${process.env.EXPO_PUBLIC_API_URL ?? 'https://api.bridgelet.org'}/claims/${encodeURIComponent(token)}/validate`,
72-
{ method: 'GET', headers: { Accept: 'application/json' } },
75+
`${process.env.EXPO_PUBLIC_API_URL ?? 'https://api.bridgelet.io'}/claims/verify`,
76+
{
77+
method: 'POST',
78+
headers: {
79+
'Content-Type': 'application/json',
80+
Accept: 'application/json',
81+
...(process.env.EXPO_PUBLIC_API_KEY
82+
? { 'X-API-Key': process.env.EXPO_PUBLIC_API_KEY }
83+
: {}),
84+
},
85+
body: JSON.stringify({ claimToken: token }),
86+
},
7387
);
7488

75-
if (response.status === 404) return 'invalid';
76-
if (response.status === 410) return 'expired';
7789
if (response.status === 409) return 'already_claimed';
90+
if (response.status === 400) return 'invalid'; // initialized/no payment yet
91+
if (response.status === 401) return 'expired';
7892
if (response.ok) return 'valid';
7993

8094
return 'invalid';

mobile/app/src/notifications/notifications.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@
1919
import * as Notifications from 'expo-notifications';
2020
import AsyncStorage from '@react-native-async-storage/async-storage';
2121
import { Platform } from 'react-native';
22+
import env from '../config/env';
2223

2324
// ─── Constants ────────────────────────────────────────────────────────────────
2425

2526
const PUSH_TOKEN_KEY = '@bridgelet:push-token';
2627
const PERMISSION_ASKED_KEY = '@bridgelet:notifications-permission-asked';
2728

28-
const API_BASE = process.env.EXPO_PUBLIC_API_URL ?? 'https://api.bridgelet.org';
29+
const API_BASE = env.apiUrl;
2930

3031
// ─── Notification types ───────────────────────────────────────────────────────
3132

mobile/app/src/sender/SenderFlow.tsx

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
1414
import { TransferService } from './TransferService';
1515
import { ShareSheet } from './ShareSheet';
16+
import { useMobileWallet } from '../hooks/useMobileWallet';
1617
import { CreateAccountRequest, CreateAccountResponse, SupportedAsset } from '../types/api';
1718

1819
type Step = 'amount' | 'recipient' | 'review' | 'success' | 'error';
@@ -24,6 +25,8 @@ export const SenderFlow: React.FC = () => {
2425
const [error, setError] = useState<string | null>(null);
2526
const [response, setResponse] = useState<CreateAccountResponse | null>(null);
2627
const [showShareSheet, setShowShareSheet] = useState(false);
28+
const { session } = useMobileWallet();
29+
const connectedPublicKey = session?.publicKey ?? null;
2730

2831
// Form State
2932
const [amount, setAmount] = useState('');
@@ -48,9 +51,23 @@ export const SenderFlow: React.FC = () => {
4851
setLoading(true);
4952
setError(null);
5053

54+
// `fundingSource` / `recovery_address` are required by the SDK. They must
55+
// come from a connected Stellar wallet session; surface a clear error if
56+
// no wallet is connected instead of sending a malformed request.
57+
if (!connectedPublicKey) {
58+
setError('Connect a Stellar wallet to send a payment.');
59+
setStep('error');
60+
setLoading(false);
61+
return;
62+
}
63+
64+
const isNative = selectedAsset.code === 'XLM';
5165
const request: CreateAccountRequest = {
66+
fundingSource: connectedPublicKey,
67+
recovery_address: connectedPublicKey,
5268
amount,
53-
asset: `${selectedAsset.code}:${selectedAsset.issuer}`,
69+
asset_code: isNative ? 'XLM' : selectedAsset.code,
70+
asset_issuer: isNative ? undefined : selectedAsset.issuer,
5471
expiresIn: 30 * 24 * 60 * 60, // Default 30 days
5572
metadata: {
5673
recipientName,
@@ -209,16 +226,18 @@ export const SenderFlow: React.FC = () => {
209226

210227
<View style={styles.linkCard}>
211228
<Text style={styles.linkText} numberOfLines={1} ellipsizeMode="middle">
212-
{response?.claimUrl}
229+
{response?.claimUrl ?? 'Claim link unavailable'}
213230
</Text>
214231
</View>
215232

216-
<TouchableOpacity
217-
style={styles.shareButton}
218-
onPress={() => setShowShareSheet(true)}
219-
>
220-
<Text style={styles.shareButtonText}>📤 Share Claim Link</Text>
221-
</TouchableOpacity>
233+
{response?.claimUrl && (
234+
<TouchableOpacity
235+
style={styles.shareButton}
236+
onPress={() => setShowShareSheet(true)}
237+
>
238+
<Text style={styles.shareButtonText}>📤 Share Claim Link</Text>
239+
</TouchableOpacity>
240+
)}
222241

223242
<TouchableOpacity
224243
style={styles.primaryButton}
@@ -232,7 +251,7 @@ export const SenderFlow: React.FC = () => {
232251
<Text style={styles.buttonText}>Create Another</Text>
233252
</TouchableOpacity>
234253

235-
{response && (
254+
{response?.claimUrl && (
236255
<ShareSheet
237256
visible={showShareSheet}
238257
claimUrl={response.claimUrl}
Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
11
import { apiClient } from '../utils/apiClient';
22
import { CreateAccountRequest, CreateAccountResponse, SupportedAsset } from '../types/api';
33

4+
const DEFAULT_ASSETS: SupportedAsset[] = [
5+
{
6+
code: 'XLM',
7+
issuer: 'native',
8+
name: 'Stellar Lumens',
9+
},
10+
{
11+
code: 'USDC',
12+
issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5',
13+
name: 'USD Coin',
14+
},
15+
];
16+
417
/**
5-
* Service to handle transfer operations and ephemeral account management
18+
* Service to handle transfer operations and ephemeral account management.
19+
* Talks to the Bridgelet SDK's `POST /accounts` endpoint.
620
*/
721
export class TransferService {
822
/**
@@ -12,7 +26,7 @@ export class TransferService {
1226
request: CreateAccountRequest
1327
): Promise<CreateAccountResponse> {
1428
try {
15-
const response = await apiClient<CreateAccountResponse>('/api/accounts', {
29+
const response = await apiClient<CreateAccountResponse>('/accounts', {
1630
method: 'POST',
1731
body: JSON.stringify(request),
1832
});
@@ -24,30 +38,11 @@ export class TransferService {
2438
}
2539

2640
/**
27-
* Fetch supported assets for transfers
41+
* Fetch supported assets for transfers.
42+
* The Bridgelet SDK does not currently expose an `/assets` endpoint, so
43+
* assets are resolved client-side (with a documented default set).
2844
*/
2945
static async getSupportedAssets(): Promise<SupportedAsset[]> {
30-
try {
31-
const response = await apiClient<{ assets: SupportedAsset[] }>('/api/assets', {
32-
method: 'GET',
33-
skipAuth: true, // Asset list is usually public
34-
});
35-
return response.assets;
36-
} catch (error) {
37-
console.error('[TransferService] Failed to fetch supported assets:', error);
38-
// Return defaults if API fails
39-
return [
40-
{
41-
code: 'XLM',
42-
issuer: 'native',
43-
name: 'Stellar Lumens',
44-
},
45-
{
46-
code: 'USDC',
47-
issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5',
48-
name: 'USD Coin',
49-
},
50-
];
51-
}
46+
return DEFAULT_ASSETS;
5247
}
5348
}

0 commit comments

Comments
 (0)