Skip to content
Draft
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 core/src/main/java/com/altude/core/model/TransactionSigner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,14 @@ interface TransactionSigner : Signer {
* @throws Exception if signing fails (e.g., biometric unavailable, key derivation error)
*/
override suspend fun signMessage(message: ByteArray): ByteArray

/**
* Pre-authenticate and unlock the signer without producing a signature.
* After this returns, [publicKey] is guaranteed to be accessible and the next
* [signMessage] call will reuse the unlocked state (avoiding a second prompt).
*
* The default implementation is a no-op; override in vault-backed signers
* that need an explicit unlock step (e.g. VaultSigner).
*/
suspend fun ensureUnlocked() { /* no-op by default */ }
}
16 changes: 8 additions & 8 deletions gasstation/src/main/java/com/altude/gasstation/GaslessManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ object GaslessManager {
suspend fun transferToken(option: ISendOption, signer: TransactionSigner? = null): Result<String> = withContext(Dispatchers.IO) {
return@withContext try {
val signerToUse = resolveSigner(option.account, signer)
ensureBiometricAuth(signerToUse, "transfer")
ensureBiometricAuth(signerToUse)
// After biometric unlock the public key is always available - use it as account
val ownerKey = signerToUse.publicKey
val pubKeyMint = PublicKey(option.token)
Expand Down Expand Up @@ -113,7 +113,7 @@ object GaslessManager {
withContext(Dispatchers.IO) {
return@withContext try {
val finalSigners = resolveSignersForBatch(options, signers)
ensureBiometricAuth(finalSigners.first(), "batch-transfer")
ensureBiometricAuth(finalSigners.first())
val transferInstructions = mutableListOf<TransactionInstruction>()

options.forEach { option ->
Expand Down Expand Up @@ -175,7 +175,7 @@ object GaslessManager {
withContext(Dispatchers.IO) {
return@withContext try {
val signerToUse = resolveSigner(option.account, signer)
ensureBiometricAuth(signerToUse, "create-account")
ensureBiometricAuth(signerToUse)
val ownerKey = signerToUse.publicKey

val txInstructions = mutableListOf<TransactionInstruction>()
Expand Down Expand Up @@ -230,7 +230,7 @@ object GaslessManager {
): Result<String> = withContext(Dispatchers.IO) {
return@withContext try {
val signerToUse = resolveSigner(option.account, signer)
ensureBiometricAuth(signerToUse, "close-account")
ensureBiometricAuth(signerToUse)
val ownerKey = signerToUse.publicKey
?: option.account.takeIf { it.isNotBlank() }?.let { PublicKey(it) }
?: throw IllegalArgumentException("Account public key required to close accounts")
Expand Down Expand Up @@ -298,7 +298,7 @@ object GaslessManager {
): Result<AltudeTransaction> = withContext(Dispatchers.IO) {
try {
val signerToUse = resolveSigner(option.account, signer)
ensureBiometricAuth(signerToUse, "swap")
ensureBiometricAuth(signerToUse)
val ownerKey = signerToUse.publicKey
val decimals = Utility.getTokenDecimals(option.inputMint)
val rawAmount = (option.amount * (10.0.pow(decimals))).toLong()
Expand Down Expand Up @@ -428,7 +428,7 @@ object GaslessManager {
): Result<String> = withContext(Dispatchers.IO) {
try {
val signerToUse = resolveSigner(option.account, signer)
ensureBiometricAuth(signerToUse, "swap")
ensureBiometricAuth(signerToUse)
val decimals = Utility.getTokenDecimals(option.inputMint)
val rawAmount = (option.amount * (10.0.pow(decimals))).toLong()
val service = SwapConfig.createService(SwapService::class.java)
Expand Down Expand Up @@ -519,9 +519,9 @@ object GaslessManager {
}
}

private suspend fun ensureBiometricAuth(signer: TransactionSigner, purpose: String) {
private suspend fun ensureBiometricAuth(signer: TransactionSigner) {
withContext(Dispatchers.Main) {
signer.signMessage("auth:$purpose".toByteArray())
signer.ensureUnlocked()
}
}

Expand Down
99 changes: 76 additions & 23 deletions vault/src/main/java/com/altude/vault/model/VaultSigner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.fragment.app.FragmentActivity
import com.altude.core.model.TransactionSigner
import com.altude.vault.crypto.VaultCrypto
import com.altude.vault.manager.VaultManager
import foundation.metaplex.solanaeddsa.Keypair
import foundation.metaplex.solanapublickeys.PublicKey

/**
Expand Down Expand Up @@ -98,6 +99,12 @@ class VaultSigner(
// Cache public key after first derivation - can be pre-set at construction
private var cachedPublicKey: PublicKey? = initialPublicKey

// Keypair cached by ensureUnlocked() for single-use by the next signMessage() call.
// Avoids a second biometric prompt when the caller pre-authenticates before building
// a transaction and then signs it immediately after (PerOperation mode).
@Volatile
private var pendingKeypair: Keypair? = null

/**
* Custom messages for biometric authentication prompts.
*
Expand Down Expand Up @@ -139,12 +146,57 @@ class VaultSigner(
cachedPublicKey = publicKey
}

/**
* Pre-authenticate without producing a signature.
*
* Performs biometric authentication (one prompt) and caches the resulting keypair so
* that the next [signMessage] call can reuse it without a second biometric prompt.
* This is the preferred way to unlock the vault before building a transaction, and
* avoids the old pattern of signing a dummy message just to obtain the public key.
*
* After this returns successfully:
* - [publicKey] is guaranteed to be accessible.
* - The next [signMessage] call will consume the cached keypair (PerOperation mode)
* or reuse the TTL session (SessionBased mode) without an additional prompt.
*
* @throws VaultException if authentication fails
* @throws IllegalArgumentException if context is not FragmentActivity
*/
override suspend fun ensureUnlocked() {
if (context !is FragmentActivity) {
throw IllegalArgumentException(
"VaultSigner requires FragmentActivity context for biometric prompts. " +
"Got ${context.javaClass.simpleName} instead."
)
}

val keypair = VaultManager.unlockVault(
context = context,
appId = appId,
walletIndex = walletIndex,
sessionTTLSeconds = when (authMode) {
is VaultAuthMode.PerOperation -> 0
is VaultAuthMode.SessionBased -> authMode.sessionTTLSeconds
},
authMessages = authMessages
)

// Cache public key so callers can access it immediately after unlock
cachedPublicKey = PublicKey(keypair.publicKey.toByteArray())

// Store keypair for single-use by the next signMessage() call (PerOperation mode).
// SessionBased mode relies on VaultManager's TTL session cache instead.
if (authMode is VaultAuthMode.PerOperation) {
pendingKeypair = keypair
}
}

/**
* Sign a message with the vault's Ed25519 keypair.
* This is the core signing operation used by all transaction builders.
*
* Authentication behavior depends on authMode:
* - PerOperation: Always prompts user for biometric
* - PerOperation: Reuses keypair cached by [ensureUnlocked] if available; otherwise prompts
* - SessionBased: Prompts if session expired, reuses cached keypair otherwise
*
* @param message Transaction message bytes to sign
Expand All @@ -160,29 +212,30 @@ class VaultSigner(
)
}

// Get keypair based on auth mode
val keypair = when (authMode) {
is VaultAuthMode.PerOperation -> {
// Always unlock (will prompt if not using session mode)
VaultManager.unlockVault(
context = context,
appId = appId,
walletIndex = walletIndex,
sessionTTLSeconds = 0, // 0 means no session caching
authMessages = authMessages
)
// Consume the keypair cached by ensureUnlocked() if present (avoids a second prompt)
val keypair = pendingKeypair?.also { pendingKeypair = null }
?: when (authMode) {
is VaultAuthMode.PerOperation -> {
// No pending keypair; must prompt the user
VaultManager.unlockVault(
context = context,
appId = appId,
walletIndex = walletIndex,
sessionTTLSeconds = 0,
authMessages = authMessages
)
}
is VaultAuthMode.SessionBased -> {
// Unlock with TTL-based session (may reuse cached session without prompt)
VaultManager.unlockVault(
context = context,
appId = appId,
walletIndex = walletIndex,
sessionTTLSeconds = authMode.sessionTTLSeconds,
authMessages = authMessages
)
}
}
is VaultAuthMode.SessionBased -> {
// Unlock with TTL-based session
VaultManager.unlockVault(
context = context,
appId = appId,
walletIndex = walletIndex,
sessionTTLSeconds = authMode.sessionTTLSeconds,
authMessages = authMessages
)
}
}

// Cache public key after first successful unlock
cachedPublicKey = PublicKey(keypair.publicKey.toByteArray())
Expand Down