Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/mobile-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ jobs:
distribution: temurin
java-version: '17'

- uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4.0.4
# v4 no longer installs the removed `tools` SDK package (v3.2.2 ran
# `sdkmanager tools`, which now fails with "Failed to find package
# 'tools'"); it defaults to `platform-tools` instead.
Expand Down
46 changes: 46 additions & 0 deletions frontend/mobile/app/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default function LoginScreen() {

const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [walletAddress, setWalletAddress] = useState('');
const [showAddress, setShowAddress] = useState(false);
const [address, setAddress] = useState('');

Expand All @@ -52,6 +53,8 @@ export default function LoginScreen() {
setBusy(true);
setError(null);
try {
await loginWithAddress(walletAddress);
router.replace('/dashboard');
const result = await loginWithAddress(address);
router.replace('/dashboard');
void result;
Expand Down Expand Up @@ -94,6 +97,28 @@ export default function LoginScreen() {
</Pressable>
)}

<TextInput
testID="login-address-input"
accessibilityLabel="Wallet contract address"
value={walletAddress}
onChangeText={setWalletAddress}
placeholder="C... wallet address"
placeholderTextColor={colors.textFaint}
autoCapitalize="characters"
autoCorrect={false}
editable={!busy}
style={styles.addressInput}
/>
<Pressable
testID="login-address-button"
accessibilityRole="button"
disabled={busy || !walletAddress.trim()}
onPress={handleAddressLogin}
style={({ pressed }) => [styles.ctaSecondary, busy && styles.disabled, pressed && styles.pressed]}
>
{busy ? <ActivityIndicator color={colors.accent} /> : <Text style={styles.ctaSecondaryText}>Sign in with address</Text>}
</Pressable>

<Pressable
testID="login-address-toggle"
accessibilityRole="button"
Expand Down Expand Up @@ -178,6 +203,27 @@ const createStyles = (colors: ThemeColors) =>
disabled: { opacity: 0.5 },
pressed: { opacity: 0.85 },
ctaText: { color: colors.onAccent, fontFamily: fontFamily.bodySemiBold, fontSize: 15 },
addressInput: {
color: colors.textPrimary,
fontFamily: fontFamily.address,
fontSize: 13,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surfaceMd,
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 13,
marginTop: 18,
},
ctaSecondary: {
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: colors.accent,
borderRadius: 100,
paddingVertical: 15,
},
ctaSecondaryText: { color: colors.accent, fontFamily: fontFamily.bodySemiBold, fontSize: 14 },
card: {
backgroundColor: colors.surface,
borderWidth: 1,
Expand Down
3 changes: 3 additions & 0 deletions frontend/mobile/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ const expoPreset = require('jest-expo/jest-preset');
*/
module.exports = {
...expoPreset,
// Shared SDK source is imported from the sibling package, so resolve its
// runtime dependencies from this app's plain npm install.
modulePaths: ['<rootDir>/node_modules'],
setupFiles: [...expoPreset.setupFiles, '<rootDir>/jest.setup.js'],
modulePaths: ['<rootDir>/node_modules'],
transformIgnorePatterns: expoPreset.transformIgnorePatterns.map((pattern) =>
Expand Down
28 changes: 28 additions & 0 deletions frontend/mobile/lib/__tests__/signerVerification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { recoverWalletByAddress } from '../../../../sdk/src/recovery/signerVerification';
import { WalletContractNotFoundError } from '../../../../sdk/src/recovery/signerErrors';
import { ADDRESS_RECOVERY_CASES } from '../../../../sdk/tests/fixtures/addressRecoveryCases';

const REGISTERED = '04' + '11'.repeat(64);
const UNREGISTERED = '04' + '22'.repeat(64);

describe('mobile recovery uses the shared signer table', () => {
it.each(ADDRESS_RECOVERY_CASES)('$name', async (testCase) => {
const resolveSigners = jest.fn(async () => {
if (testCase.resolution === 'not-found') throw new WalletContractNotFoundError(testCase.address);
if (testCase.resolution === 'network-error') throw new Error('RPC unavailable');
return testCase.resolution === 'empty' ? [] : [REGISTERED];
});
const authenticate = jest.fn(async () => testCase.authentication === 'registered' ? REGISTERED : UNREGISTERED);

if (testCase.expected === 'accepted') {
await expect(recoverWalletByAddress(testCase.address, { resolveSigners, authenticate })).resolves.toMatchObject({
address: testCase.address,
publicKey: REGISTERED,
});
} else {
await expect(recoverWalletByAddress(testCase.address, { resolveSigners, authenticate }))
.rejects.toMatchObject({ name: testCase.expected });
}
expect(resolveSigners).toHaveBeenCalledTimes(testCase.expected === 'InvalidWalletAddressError' ? 0 : 1);
});
});
35 changes: 35 additions & 0 deletions frontend/mobile/lib/passkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,41 @@ export function nativePrfEvaluator(credentialId: string): (salt: Uint8Array) =>
return async (salt: Uint8Array) => (await evaluatePrf(credentialId, salt)).output;
}

export type DiscoveredPasskeyAssertion = {
credentialId: string;
authenticatorData: Uint8Array;
clientDataJSON: Uint8Array;
signature: Uint8Array;
prf: Uint8Array | null;
};

/** Discover a passkey once, retaining the assertion fields used by shared recovery verification. */
export async function discoverPasskeyAssertion(salt: Uint8Array): Promise<DiscoveredPasskeyAssertion | null> {
try {
const assertion = await passkeys().get({
challenge: uint8ArrayToBase64Url(Crypto.getRandomBytes(32)),
rpId: getRelyingPartyId(),
userVerification: 'required',
timeout: 60_000,
extensions: { prf: { eval: { first: uint8ArrayToBase64Url(salt) } } },
});
if (!assertion) return null;
const credentialId = (assertion as { id?: string; rawId?: string }).id
?? (assertion as { id?: string; rawId?: string }).rawId;
if (!credentialId) return null;
return {
credentialId,
authenticatorData: base64UrlToUint8Array(assertion.response.authenticatorData),
clientDataJSON: base64UrlToUint8Array(assertion.response.clientDataJSON),
signature: base64UrlToUint8Array(assertion.response.signature),
prf: parsePrfOutput(assertion),
};
} catch (error: unknown) {
if (isUserRejection(error)) return null;
throw error;
}
}

/**
* A discovered assertion: one pic-and-tap passkey gesture, plus the pieces of
* the resulting WebAuthn assertion that let a caller verify the credential's
Expand Down
49 changes: 49 additions & 0 deletions frontend/mobile/lib/passkeyLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { sha256 } from '@noble/hashes/sha2.js';
import { Keypair, StrKey } from '@stellar/stellar-sdk';
import { Buffer } from 'buffer';

import { discoverPasskeyAssertion, discoverWithPrf, nativePrfEvaluator } from './passkey';
import { recoverWalletByAddress, matchWebAuthnSigner } from '../../../sdk/src/recovery/signerVerification';
import { getNetwork, getNetworkName } from './network';
import { recordFeePayerSource } from './feePayerSource';
import { getNetworkName } from './network';
import { discoverWithPrf, nativePrfEvaluator, type DiscoveredPasskey } from './passkey';
Expand Down Expand Up @@ -141,6 +144,52 @@ export async function loginWithPasskey(): Promise<LoginResult> {
return { address: crumbs.walletAddress, source: 'recovered' };
}

/** Recover by a supplied wallet address using the shared validate/resolve/verify sequence. */
export async function loginWithAddress(address: string): Promise<LoginResult> {
const pickedRef = { value: null as Awaited<ReturnType<typeof discoverPasskeyAssertion>> };
const network = getNetwork();
const result = await recoverWalletByAddress(address, {
rpcUrl: network.rpcUrl,
networkPassphrase: network.networkPassphrase,
authenticate: async (signers) => {
pickedRef.value = await discoverPasskeyAssertion(FEE_PAYER_PRF_SALT);
if (!pickedRef.value) throw new Error('Passkey prompt was cancelled.');
return matchWebAuthnSigner(signers, pickedRef.value);
},
});
const picked = pickedRef.value;
if (!picked) throw new Error('Passkey sign-in was cancelled.');

const networkSuffix = getNetworkName() === 'mainnet' ? '_mainnet' : '';
const [existingSecret, existingAddress, existingSdkAddress] = await Promise.all([
getSignerSecret().catch(() => null),
getWalletAddress().catch(() => null),
AsyncStorage.getItem(`${SDK_ADDRESS}${networkSuffix}`).catch(() => null),
]);
if ((existingAddress || existingSdkAddress) && !existingSecret && (!picked.prf || picked.prf.length < 32)) {
throw new Error('This device has an existing wallet but its fee-payer key is missing. Sign in with the original passkey on a PRF-capable device or restore the fee-payer before continuing.');
}
const feePayer = existingSecret
? Keypair.fromSecret(existingSecret)
: picked.prf && picked.prf.length >= 32
? Keypair.fromRawEd25519Seed(Buffer.from(picked.prf.subarray(0, 32)))
: Keypair.random();
const publicKeyHex = result.publicKey.toLowerCase();

await Promise.all([
setWalletAddress(result.address),
setSignerSecret(feePayer.secret()),
setPasskeyCredential(picked.credentialId, publicKeyHex),
]);
await AsyncStorage.multiSet([
[`${SDK_ADDRESS}${networkSuffix}`, result.address],
[`${SDK_KEY_ID}${networkSuffix}`, picked.credentialId],
[`${SDK_PUBLIC_KEY}${networkSuffix}`, publicKeyHex],
]);
void writeBreadcrumbs(feePayer.secret(), result.address, new Uint8Array(Buffer.from(publicKeyHex, 'hex')))
.catch(() => undefined);

return { address: result.address, source: 'recovered' };
/**
* Sign in to a wallet the user knows only by its C-address.
*
Expand Down
4 changes: 2 additions & 2 deletions frontend/mobile/lib/recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type * as PasskeysModule from 'react-native-passkeys';
import { getNetwork, type VeilNetwork } from './network';
import { setPasskeyCredential, setWalletAddress } from './walletStore';
import { base64UrlToUint8Array, uint8ArrayToBase64Url } from './webauthn';
import { isRegisteredSigner } from '../../../sdk/src/recovery/signerRegistry';

let cachedPasskeys: typeof PasskeysModule | null | undefined;
function passkeys(): typeof PasskeysModule {
Expand Down Expand Up @@ -462,9 +463,8 @@ export async function walletHasSigner(
publicKey: Uint8Array,
network: VeilNetwork = getNetwork()
): Promise<boolean> {
const target = toHex(publicKey);
const signers = await fetchWalletSigners(walletAddress, network);
return signers.some((signer) => toHex(signer) === target);
return isRegisteredSigner(signers, publicKey);
}

// ── Recovery transactions ─────────────────────────────────────────────────────
Expand Down
20 changes: 3 additions & 17 deletions frontend/mobile/lib/signers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
} from '@stellar/stellar-sdk';

import { getNetwork } from './network';
import { NotVeilWalletError, WalletContractNotFoundError } from '../../../sdk/src/recovery/signerErrors';
export { WalletContractNotFoundError } from '../../../sdk/src/recovery/signerErrors';

export type WalletSigner = {
/** The signer's slot in the contract's signer map. */
Expand All @@ -34,22 +36,6 @@ function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}

/**
* Raised by {@link readSigners} when the RPC answered but no `get_signers`
* instance exists at the address. Distinct from the network being unreachable,
* which surfaces as whatever the RPC threw instead — callers catch this type
* to tell the two apart.
*/
export class WalletContractNotFoundError extends Error {
readonly contractAddress: string;

constructor(contractAddress: string) {
super(`No wallet contract is deployed at ${contractAddress} on this network.`);
this.name = 'WalletContractNotFoundError';
this.contractAddress = contractAddress;
}
}

/**
* Simulation diagnostics that mean "there is no contract here" rather than
* "the call failed". Anything else coming back as a simulation error is left
Expand Down Expand Up @@ -86,7 +72,7 @@ export async function readSigners(contractAddress: string): Promise<WalletSigner
if (CONTRACT_MISSING_RE.test(sim.error)) {
throw new WalletContractNotFoundError(contractAddress);
}
throw new Error(sim.error);
throw new NotVeilWalletError();
}

const result = (sim as SorobanRpc.Api.SimulateTransactionSuccessResponse).result;
Expand Down
24 changes: 24 additions & 0 deletions frontend/wallet/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { getDueSchedules, updateSchedule, advanceNextRun, type PaymentSchedule }
import { Amount, Row, TokenIcon } from '@/components/ui/primitives'
import { formatFiat, hydrateCurrency, useCurrency } from '@/lib/currency'
import { useActivityFeed, initActivityFeed, hydrateActivityFeed, appendActivityFeed } from '@/lib/activityFeed'
import { QRCodeCanvas } from 'qrcode.react'
import { loadBlendPositions, type BlendPosition } from '@/lib/blend'
import { KNOWN_SAC_CONTRACT_IDS, getAssetIssuer } from '@/lib/assets'
import { buildPortfolio } from '@/lib/portfolio'
Expand Down Expand Up @@ -136,6 +137,7 @@ function DashboardPageContent() {
const [fundingError, setFundingError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [hasFeePayerKey, setHasFeePayerKey] = useState(true)
const [feePayerFunding, setFeePayerFunding] = useState<{ address: string; balance: number; required: number } | null>(null)
const [agentBadge, setAgentBadge] = useState(false)
const [contractXlm, setContractXlm] = useState(() => cachedContractXlm ?? 0)
const [isSweeping, setIsSweeping] = useState(false)
Expand Down Expand Up @@ -313,6 +315,8 @@ function DashboardPageContent() {

// Track whether fee-payer exists so we can show a recovery banner
setHasFeePayerKey(!!signerPublicKey)
const requiredFeePayerXlm = 1.01
setFeePayerFunding(signerPublicKey ? { address: signerPublicKey, balance: 0, required: requiredFeePayerXlm } : null)

let feePayerXlm = 0
let otherAssets: WalletAsset[] = []
Expand Down Expand Up @@ -342,6 +346,7 @@ function DashboardPageContent() {
horizonNextRef.current = paymentsPage.records.length >= 20 ? paymentsPage.next : null
txRecords = mapHorizonOps(paymentsPage.records as HorizonOp[], signerPublicKey)
} catch { /* not yet funded */ }
setFeePayerFunding({ address: signerPublicKey, balance: feePayerXlm, required: requiredFeePayerXlm })
}

// ── 3. Wraith: incoming SAC transfers to the wallet contract ────────────
Expand Down Expand Up @@ -786,6 +791,25 @@ function DashboardPageContent() {
</div>
)}

{!loading && feePayerFunding && feePayerFunding.balance < feePayerFunding.required && (
<div style={{ marginBottom: '1.5rem', padding: '1rem 1.25rem', background: 'var(--surface-md)', border: '1px solid var(--border-dim)', borderRadius: '12px', display: 'flex', gap: '1rem', alignItems: 'center' }}>
<QRCodeCanvas value={feePayerFunding.address} size={72} bgColor="#ffffff" fgColor="#111111" includeMargin />
<div style={{ flex: 1 }}>
<p style={{ fontSize: '0.875rem', color: 'var(--off-white)', marginBottom: '0.375rem', fontWeight: 500 }}>Fee-payer needs funding</p>
<p style={{ fontSize: '0.8125rem', color: 'rgba(246,247,248,0.65)', marginBottom: '0.5rem', lineHeight: 1.5 }}>
Send at least {feePayerFunding.required.toFixed(2)} XLM to this G... account. It pays network fees; it is separate from your wallet balance.
</p>
<button
className="btn-secondary"
onClick={() => void navigator.clipboard?.writeText(feePayerFunding.address)}
style={{ fontSize: '0.75rem', padding: '0.4rem 0.75rem', width: 'auto' }}
>
Copy {feePayerFunding.address.slice(0, 6)}…{feePayerFunding.address.slice(-4)}
</button>
</div>
</div>
)}

{/* ── PRF downgrade warning banner (issue #629) ── */}
{!loading && showPrfDowngrade && !prfDowngradeDismissed && (
<div style={{
Expand Down
3 changes: 3 additions & 0 deletions frontend/wallet/app/lock/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,9 @@ export default function LockPage() {
<p style={{ fontSize: '0.75rem', color: 'var(--color-muted)', textAlign: 'center' }}>
Your biometric is your key — no password needed.
</p>
<button type="button" className="btn-ghost" onClick={() => router.push('/sign-in')}>
Sign in with another address or backup
</button>
</div>
</main>
</div>
Expand Down
3 changes: 3 additions & 0 deletions frontend/wallet/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ export default function OnboardingPage() {
<button id="onboarding-recover" className="btn-ghost" onClick={() => router.push('/recover')}>
Recover existing wallet
</button>
<button id="onboarding-sign-in" className="btn-ghost" onClick={() => router.push('/sign-in')}>
Sign in with wallet address or backup
</button>
{error && (
<p style={{ fontSize: '0.8125rem', color: 'var(--teal)', textAlign: 'center', marginTop: '0.5rem' }}>
{error}
Expand Down
Loading