Purpose: Document the complete architecture for claiming a covenant from notification to on-chain transaction.
Status: ✅ Production-proven (August 10, 2026)
Evidence: First successful claim TXID: 193c3c9e5287e13cc56e1401aed55de34db9a375312e052807aea060e58e3d96
The covenant claim flow enables a recipient to receive a guaranteed EUR-denominated payment from a covenant created by a sender on a different device.
Key properties:
Flow duration: ~30 seconds (notification → claim → confirmation)
Note (Aug 20-21, 2026): This diagram documents the v0.1 flow. In the v0.2 hybrid, the WebView no longer broadcasts — it builds+signs and returns hex; Kotlin broadcasts via
ElectrumClient(TCP 60001). The flow steps below are unchanged in spirit; only the network layer moved. See webview-covenant-bridge.md.
┌─────────────────────────────────────────────────────────────────────┐
│ SENDER DEVICE (Moto G06) │
│ │
│ 1. Create Covenant │
│ ├─ ReviewSendActivity │
│ ├─ CovenantWebView.createCovenant() │
│ └─ Returns covenant address │
│ │
│ 2. Fund Covenant (v0.2: sendBchHybrid — WebView builds, Kotlin broadcasts)│
│ ├─ CovenantWebView.sendBchHybrid() │
│ ├─ WebView builds+signs (libauth), Kotlin broadcasts (TCP) │
│ └─ Returns funding TXID │
│ │
│ 3. Share Parameters │
│ ├─ Format [COVENANT_V25] block │
│ ├─ Auto-copy to clipboard │
│ └─ User pastes to Telegram → sends to recipient │
└─────────────────────────────────────────────────────────────────────┘
↓
[Telegram Message]
↓
┌─────────────────────────────────────────────────────────────────────┐
│ RECIPIENT DEVICE (Pixel 6a) │
│ │
│ 4. NotificationListener (Background Service) │
│ ├─ Android AccessibilityService │
│ ├─ Monitors Telegram notifications │
│ ├─ Detects [COVENANT_V25] block │
│ ├─ Parses covenant parameters (key=value format) │
│ └─ Stores in database (isReceived = true) │
│ │
│ 5. Remittance UI (User Action) │
│ ├─ RemittanceAdapter shows "Someone sent you X €" │
│ ├─ User taps "🔄 Update Status" │
│ │ ├─ ElectrumClient queries balance (TCP port 60001) │
│ │ ├─ Shows "✅ funded" or "⚠️ Expired" │
│ │ └─ 5-second TCP cooldown (prevents race condition) │
│ ├─ User taps "💰 Claim" → Dialog appears │
│ └─ User taps "💰 CLAIM NOW" │
│ │
│ 6. Wallet Matching (Critical Step!) │
│ ├─ Find recipient wallet: walletManager.findWalletByPubkey( │
│ │ remittance.recipientPubkey) │
│ ├─ Extract recipient WIF (for signing) │
│ ├─ Find SELLER wallet: walletManager.findWalletByPubkey( │
│ │ remittance.sellerPubkey) ← [Funder Principle!] │
│ └─ Extract seller ADDRESS (for buffer output) │
│ │
│ 7. Oracle Signature Generation │
│ ├─ Get current BCH/EUR price (Pi-chan oracle /oracle/price) │
│ ├─ Generate timestamp (Unix epoch) │
│ ├─ CovenantWebView.generateOracleSignature() │
│ └─ Returns { message, signature, price, timestamp } │
│ │
│ 8. Transaction Building (Kotlin ↔ JavaScript Bridge) │
│ ├─ RemittanceActivity.executeClaim() │
│ ├─ Builds parameters JSON: │
│ │ { │
│ │ covenantParams: { ... }, │
│ │ oracleSig: { ... }, │
│ │ recipientWIF: "...", │
│ │ recipientAddress: "bchtest:qq2uxg...", │
│ │ sellerAddress: "bchtest:qrw5nuk...", ← Critical! │
│ │ fulcrumHost: "192.168.1.100", │
│ │ fulcrumPort: 60003 │
│ │ } │
│ └─ Calls CovenantWebView.claimCovenantHybrid(params) (v0.2) │
│ │
│ 9. JavaScript Execution (covenant-bridge.html) │
│ ├─ Connect to Fulcrum briefly (WebSocket 60003, UTXO fetch) │
│ ├─ Recreate covenant contract from params │
│ ├─ Fetch covenant UTXO (contract.getUtxos()) │
│ ├─ Calculate payment amounts: │
│ │ ├─ eurPayment = eurCents ÷ oraclePrice × 100000000 │
│ │ └─ buffer = utxoSats - eurPayment - feeSats │
│ ├─ Build transaction (build(), NOT send()): │
│ │ ├─ Input: Covenant UTXO + unlock script │
│ │ ├─ Output 0: eurPayment sats → recipientAddress │
│ │ └─ Output 1: buffer sats → sellerAddress │
│ ├─ Sign with recipientWIF │
│ ├─ Return { txHex } via Android.onTransactionBuilt() │
│ └─ Disconnect from Fulcrum (finally block) │
│ │
│ 10. Kotlin Broadcast (v0.2 hybrid) │
│ ├─ CovenantBuildService.broadcastTransaction(txHex) (TCP 60001) │
│ ├─ Show toast: "✅ Claim successful! TXID: ..." │
│ └─ Reload remittance history │
└─────────────────────────────────────────────────────────────────────┘
↓
[Blockchain Verification]
↓
┌─────────────────────────────────────────────────────────────────────┐
│ PI-CHAN FULCRUM NODE (Testnet) │
│ │
│ 11. Transaction Validation │
│ ├─ Verify covenant script execution │
│ ├─ Validate oracle signature (CHECKDATASIG) │
│ ├─ Check output 0 amount (exact EUR payment) │
│ ├─ Check output 0 address (matches recipientPubkey) │
│ ├─ Check output 1 amount (buffer) │
│ ├─ Check output 1 address (matches sellerPubkey) ← Funder! │
│ └─ Broadcast to network │
│ │
│ 12. Confirmation │
│ ├─ Transaction enters mempool │
│ ├─ Miner includes in block │
│ └─ Both sender and recipient wallets show confirmed │
└─────────────────────────────────────────────────────────────────────┘
Message Format:
🎁 You received 5,00 € via Asgaya BCH!
Covenant ready to claim - open Asgaya app to receive your payment.
[COVENANT_V25]
covenantAddress=bchtest:pwek84sj8vk39gzkkd5z5fg89l8t54lgh9yaymq09d665544yqvjqtxflhjf8
senderPubkey=032774f66536468a06a244568724bf1f8762682be1573ff9910667c0fea1226ece
recipientPubkey=03886b4f4100f9e348caed5155474f0a1a96db3e2706dc59e15260ed3d2b35e148
sellerPubkey=032774f66536468a06a244568724bf1f8762682be1573ff9910667c0fea1226ece
oraclePubkey=02f2c7e020377863ca973d9b68b20dc40b8da38fcb77536333cde0e886c8400651
eurCents=500
expiryOracleTime=1786313404
initialBchPriceInCents=65000
minPricePercent=93
fundingTxid=9b98c94cf4c755b3e8d2b18ad8095451f959b6cb39ddef657bdc841b08c700bb
[/COVENANT_V25]
> **Note:** This example shows the Aug 10 milestone covenant with the *original* oracle pubkey. Since Aug 16, the app fetches the oracle pubkey dynamically from the Pi-chan oracle (`ORACLE_URL` + `fetchOraclePubkey()`) — no pubkey is hardcoded in the app. See [Asgaya Oracle Husk](/the-mechanism/nostr-coordination/oracle-husk.html).
⏰ Expires: 2026-08-10 00:10:04 UTC
💰 You will receive 5,00 € worth of BCH at claim time
Open Asgaya app to claim!
Key features:
COVENANT_V25) for future compatibilityTransport options:
File: NotificationListener.kt
Architecture:
class NotificationListener : NotificationListenerService() {
override fun onNotificationPosted(sbn: StatusBarNotification) {
// 1. Extract notification extras
val notification = sbn.notification
val extras = notification.extras
// 2. Get notification text (title + content)
val title = extras.getString(Notification.EXTRA_TITLE, "")
val text = extras.getCharSequence(Notification.EXTRA_TEXT, "").toString()
val notificationText = "$title $text"
// 3. Detect [COVENANT_V25] block
if (notificationText.contains("[COVENANT_V25]")) {
parseAndStoreCovenant(notificationText)
}
}
private fun parseAndStoreCovenant(text: String) {
// 4. Extract parameter block
val covenantBlock = extractCovenantBlock(text)
// 5. Parse key=value pairs
val params = parseCovenantParams(covenantBlock)
// 6. Create Remittance entity
val remittance = Remittance(
covenantAddress = params["covenantAddress"],
recipient = "Covenant Claim", // Placeholder
senderPubkey = params["senderPubkey"],
recipientPubkey = params["recipientPubkey"],
sellerPubkey = params["sellerPubkey"],
oraclePubkey = params["oraclePubkey"],
eurCents = params["eurCents"]?.toInt(),
expiryOracleTime = params["expiryOracleTime"]?.toLong(),
initialBchPriceInCents = params["initialBchPriceInCents"]?.toInt(),
minPricePercent = params["minPricePercent"]?.toInt(),
fundingTxid = params["fundingTxid"],
isReceived = true, // ← Key flag!
timestamp = System.currentTimeMillis()
)
// 7. Store in database
remittanceDao.insertRemittance(remittance)
Log.d(TAG, "✅ Received covenant stored: €${eurCents/100.0}")
}
}
Key patterns:
Permissions required:
<service
android:name=".NotificationListener"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
User setup: Settings → Notifications → Notification access → Asgaya → Enable
Note: NotificationListenerService is less intrusive than AccessibilityService:
Entity: Remittance.kt
@Entity(tableName = "remittances")
data class Remittance(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val covenantAddress: String,
val recipient: String,
val senderPubkey: String?,
val recipientPubkey: String?,
val sellerPubkey: String?, // ← Funder's pubkey (critical!)
val oraclePubkey: String?,
val eurCents: Int?,
val expiryOracleTime: Long?,
val initialBchPriceInCents: Int?,
val minPricePercent: Int?,
val fundingTxid: String?,
val isReceived: Boolean = false, // ← Differentiates sent vs received
val timestamp: Long
)
Key field: isReceived
false = Covenant created by this device (sent)true = Covenant received via notification (claim)DAO query:
@Query("SELECT * FROM remittances ORDER BY timestamp DESC")
fun getAllRemittances(): Flow<List<Remittance>>
Flow updates UI automatically: When NotificationListener inserts a new received covenant, the Flow emits, and RemittanceAdapter updates instantly.
File: RemittanceAdapter.kt
Layout for received covenants:
┌─────────────────────────────────────┐
│ ✓ Confirmed Just now │
│ │
│ Someone sent you 5,00 € │
│ balance: 0.00769231 BCH │
│ │
│ Covenant Status: ✅ funded │
│ ⏰ 7h 13m until expires │
│ │
│ 🔄 Update Status 💰 Claim │
└─────────────────────────────────────┘
Key differences from sent covenants:
Binding logic:
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val remittance = remittances[position]
if (remittance.isReceived) {
// Received covenant layout
holder.sentSection.visibility = View.GONE
holder.receivedSection.visibility = View.VISIBLE
holder.receivedAmount.text = "Someone sent you ${formatEur(remittance.eurCents)}"
// Show countdown if expiry exists
remittance.expiryOracleTime?.let { expiry ->
val remaining = calculateTimeRemaining(expiry)
holder.expiryCountdown.text = "⏰ $remaining until expires"
holder.expiryCountdown.visibility = View.VISIBLE
}
// Claim button click
holder.claimButton.setOnClickListener {
showClaimDialog(remittance)
}
} else {
// Sent covenant layout (normal)
// ... (existing sent covenant UI)
}
}
Pattern: User-triggered balance query (not automatic)
Why manual:
Implementation:
holder.checkBalanceButton.setOnClickListener {
CoroutineScope(Dispatchers.IO).launch {
try {
// Query balance via TCP (ElectrumClient)
val balance = electrumClient.getBalance(
remittance.covenantAddress,
host = "192.168.1.100",
port = 60001 // TCP port (not WebSocket!)
)
withContext(Dispatchers.Main) {
if (balance > 0) {
holder.covenantStatus.text = "✅ funded"
holder.claimButton.visibility = View.VISIBLE
} else {
holder.covenantStatus.text = "✅ Claimed or refunded"
holder.claimButton.visibility = View.GONE
}
// ⚠️ CRITICAL: 5-second TCP cooldown
// Wait for OS to fully close TCP connection before
// allowing WebSocket operations (claim/refund)
Log.d(TAG, "⏳ Waiting 5s for TCP connection cleanup...")
delay(5000) // Android OS needs time to release TCP socket
Log.d(TAG, "✅ TCP connection cleanup complete")
}
} catch (e: Exception) {
// Handle error
}
}
}
TCP Cooldown Rationale:
⚠️ Phase 0 Workaround:
This 5-second blocking delay works for testing but is a UX concern at scale. A user tapping “Update Status” → “Claim” experiences a ~5-second pause before the claim button responds. Future improvements should explore:
For now: The 5-second delay is documented, tested, and prevents production hangs. It’s acceptable for Phase 0 single-device-pair testing.
File: RemittanceActivity.kt → executeClaim()
The Challenge:
A covenant has multiple pubkeys:
senderPubkey - Who created the covenantrecipientPubkey - Who can claim the paymentsellerPubkey - Who funded the covenant (gets buffer back!)The recipient device must:
recipientPubkey (for signing the claim)sellerPubkey (for buffer output address)Implementation:
private fun executeClaim(remittance: Remittance) {
lifecycleScope.launch {
// 1. Find RECIPIENT wallet (for signing)
val recipientPubkey = remittance.recipientPubkey
?: throw Exception("No recipientPubkey in remittance")
Log.d(TAG, "Looking for recipient wallet: ${recipientPubkey.take(20)}...")
val recipientWallet = walletManager.findWalletByPubkey(recipientPubkey)
?: throw Exception("You don't have the recipient wallet")
Log.d(TAG, "✅ Found recipient wallet: ${recipientWallet.label}")
val recipientWIF = walletManager.getWIF(recipientWallet)
// 2. Find SELLER wallet (for buffer output!)
// ⚠️ CRITICAL: sellerPubkey = funder (might be sender in self-funded!)
val sellerPubkey = remittance.sellerPubkey
?: throw Exception("No sellerPubkey in remittance")
Log.d(TAG, "Looking for seller wallet: ${sellerPubkey.take(20)}...")
val sellerWallet = walletManager.findWalletByPubkey(sellerPubkey)
?: throw Exception("Seller wallet not found")
Log.d(TAG, "✅ Found seller wallet: ${sellerWallet.label}")
Log.d(TAG, "Seller address (for buffer): ${sellerWallet.address}")
// 3. Generate oracle signature
val currentBchPriceInCents = fetchOraclePrice() // From Pi-chan oracle /oracle/price
val oracleSig = covenantWebView.generateOracleSignature(currentBchPriceInCents)
// 4. Call claim with BOTH wallet addresses
val txid = covenantWebView.claimCovenant(
covenantParams = buildCovenantParams(remittance),
oracleSig = oracleSig,
recipientWIF = recipientWIF,
recipientAddress = recipientWallet.address, // Payment goes here
sellerAddress = sellerWallet.address, // Buffer goes here!
fulcrumHost = "192.168.1.100",
fulcrumPort = 60003 // WebSocket port
)
Log.d(TAG, "✅ CLAIM SUCCESSFUL! TXID: $txid")
}
}
Why wallet matching is critical:
See Funder Principle - August 10 Bug for the production disaster we avoided by understanding this!
Kotlin side: CovenantWebView.kt
suspend fun claimCovenant(
covenantParams: Map<String, Any>,
oracleSig: Map<String, Any>,
recipientWIF: String,
recipientAddress: String,
sellerAddress: String, // ← Added Aug 10 (critical fix!)
fulcrumHost: String = "192.168.1.100",
fulcrumPort: Int = 60003
): String = suspendCancellableCoroutine { continuation ->
// Build parameters JSON
val params = JSONObject().apply {
put("covenantParams", JSONObject(covenantParams))
put("oracleSig", JSONObject(oracleSig))
put("recipientWIF", recipientWIF)
put("recipientAddress", recipientAddress)
put("sellerAddress", sellerAddress) // Buffer output!
put("fulcrumHost", fulcrumHost)
put("fulcrumPort", fulcrumPort)
}
// Call JavaScript
webView.post {
webView.evaluateJavascript(
"claimCovenant(\"${params.toString().replace("\"", "\\\"")}\")",
null
)
}
// Await callback
claimContinuation = { txid -> continuation.resume(txid) }
claimErrorContinuation = { error -> continuation.resumeWithException(error) }
}
JavaScript side: covenant-bridge.html → window.claimCovenant()
window.claimCovenant = async function(paramsJson) {
try {
const params = JSON.parse(paramsJson);
const {
covenantParams, oracleSig, recipientWIF,
recipientAddress, sellerAddress,
fulcrumHost, fulcrumPort
} = params;
// 1. Connect to Fulcrum (WebSocket)
const useSSL = (fulcrumPort === 60004 || fulcrumPort === 50003 || fulcrumPort === 50004);
const socket = new ElectrumWebSocket(fulcrumHost, fulcrumPort, useSSL);
const electrum = new ElectrumClient('Asgaya Claim', '1.4.1', socket);
const provider = new ElectrumNetworkProvider('testnet3', {
electrum,
manualConnectionManagement: true
});
await electrum.connect();
log('✅ Connected to Fulcrum');
// 2. Recreate covenant contract
const contract = new Contract(
ARTIFACT,
[
Buffer.from(covenantParams.senderPubkey, 'hex'),
Buffer.from(covenantParams.recipientPubkey, 'hex'),
Buffer.from(covenantParams.sellerPubkey, 'hex'),
Buffer.from(covenantParams.oraclePubkey, 'hex'),
BigInt(covenantParams.eurCents),
BigInt(covenantParams.expiryOracleTime),
BigInt(covenantParams.initialBchPriceInCents),
BigInt(covenantParams.minPricePercent)
],
{ provider, addressType: 'p2sh32' }
);
// 3. Get covenant UTXO
const utxos = await contract.getUtxos();
if (utxos.length === 0) {
throw new Error('No UTXOs - covenant not funded or already claimed');
}
const utxo = utxos[0];
// 4. Calculate payment amounts
const eurPayment = calculateEurValue(
covenantParams.eurCents,
oracleSig.price
);
const buffer = utxo.satoshis - eurPayment - 1000n; // 1000 sats fee
log(`Payment: ${eurPayment} sats to recipient`);
log(`Buffer: ${buffer} sats to seller`);
// 5. Build transaction with unlock script
const transaction = await contract.functions
.claim(
new SignatureTemplate(recipientWIF), // Recipient signs
Buffer.from(oracleSig.signature, 'hex'),
Buffer.from(oracleSig.message, 'hex')
)
.to(recipientAddress, eurPayment) // Output 0: Payment
.to(sellerAddress, buffer) // Output 1: Buffer to SELLER!
.withHardcodedFee(1000n)
.build();
// 6. Broadcast
log('Broadcasting transaction...');
const txid = await electrum.request('blockchain.transaction.broadcast',
transaction.toHex()
);
log(`✅ SUCCESS! TXID: ${txid}`);
// 7. Clean up
await electrum.disconnect();
log('🔌 Disconnected from Fulcrum');
// 8. Callback to Kotlin
Android.onClaimSuccess(txid);
} catch (error) {
log(`❌ Error: ${error.message}`);
Android.onClaimError(error.message);
} finally {
// Always disconnect (even on error!)
if (electrum) {
await electrum.disconnect();
}
}
};
Key patterns:
Covenant script validates:
function claim(
sig recipientSig,
datasig oracleSig,
bytes oracleMessage
) {
// 1. Verify recipient signature
require(checkSig(recipientSig, recipient));
// 2. Verify oracle signature
require(checkDataSig(oracleSig, oracleMessage, oraclePubkey));
// 3. Parse oracle data
int oraclePrice = int(oracleMessage.split(8)[1]);
// 4. Calculate EUR payment amount
int eurPayment = calculateEurValue(eurCents, oraclePrice);
// 5. Validate output 0 (recipient payment)
require(tx.outputs[0].value == eurPayment);
require(hash160(tx.outputs[0].lockingBytecode) == recipient);
// 6. Validate output 1 (buffer to SELLER = funder!)
int buffer = tx.inputs[0].value - eurPayment - tx.outputs[2].value;
require(tx.outputs[1].value >= buffer);
require(hash160(tx.outputs[1].lockingBytecode) == seller); // ← Key check!
}
If any check fails: Transaction rejected (e.g., August 10 bug caught here!)
First successful claim: August 10, 2026
Devices: Moto G06 (sender) → Pixel 6a (recipient)
TXID: 193c3c9e5287e13cc56e1401aed55de34db9a375312e052807aea060e58e3d96
Transaction breakdown:
Input:
Covenant UTXO: 827,129 sats
Output 0 (Recipient - Isabel):
Amount: 769,230 sats (€5.00 at €650/BCH)
Address: bchtest:qq2uxg4cu9axyzd9gjnhxwrvealt44mcwunp7gzd0k
Output 1 (Seller/Sender - Buffer):
Amount: 56,899 sats (7.4% volatility protection)
Address: bchtest:qrw5nukh5jqend8922tf8zhxwyku6wfpxu9nl79hxf
Transaction fee: 1,000 sats
Verification: bitcoin-cli -testnet gettransaction 193c3c9e...
Result: Both wallets confirmed receipt ✅
app/src/main/java/com/asgaya/husk/
├── NotificationListener.kt # Parses [COVENANT_V25] blocks
├── remittance/
│ ├── RemittanceActivity.kt # executeClaim() orchestration
│ ├── RemittanceAdapter.kt # Received covenant UI
│ └── Remittance.kt # Database entity (isReceived flag)
├── wallet/WalletManager.kt # findWalletByPubkey()
└── CovenantWebView.kt # Kotlin ↔ JS bridge
app/src/main/assets/
└── covenant-bridge.html # JavaScript claim execution
docs/
├── why-this-design/constraints/
│ └── funder-principle.md # Why buffer goes to seller
├── implementation/
│ ├── covenants/version-history.md # Covenant v2.5 spec
│ └── android-app/
│ ├── claim-flow-end-to-end.md # ← This document
│ └── connection-management-patterns.md # TCP cooldown, WebSocket cleanup
Problem: Buffer sent to recipient instead of seller
Impact: All claims rejected by covenant validation
Solution: Find seller wallet by sellerPubkey, use seller’s address
Documentation: Funder Principle - August 10 Bug
Problem: Balance query (TCP) → immediate claim (WebSocket) hangs
Root cause: Android OS needs 2-5 seconds to release TCP connection
Solution: 5-second delay after balance queries
Documentation: Connection Management Patterns (to be created)
Problem: First operation works, second hangs (zombie connections)
Root cause: disconnect() only called on success, not on error
Solution: finally blocks to ALWAYS disconnect
Status: Fixed in all WebSocket operations (sendBch, refund, claim)
Before deploying claim flow changes:
Verification command:
bitcoin-cli -testnet -rpcwallet=sender gettransaction <TXID>
bitcoin-cli -testnet -rpcwallet=recipient gettransaction <TXID>
Current (Aug 16): Pi-chan oracle husk running — /oracle/price serves signed prices, /oracle/set-price enables test price drops. Price source is still test-controlled.
Future: Real-time market feed (mainnet), then progressive decentralization to network VWAP.
See: Asgaya Oracle Husk
Priority: High (before mainnet)
Current: Telegram copy/paste
Future: Nostr DM with covenant NIP (Nostr Implementation Possibility)
Benefits: Decentralized, no reliance on Telegram
Priority: Medium
Current: “🏪 Cash out at merchant” button (placeholder)
Future: Query bulletin board for nearby merchants, QR code handoff
Priority: High (next major feature)
Current: Claim one covenant at a time
Future: Select multiple received covenants → claim all in one transaction
Benefits: Reduced fees, faster UX
Priority: Low (optimization)
End-to-end claim flow:
Key insights:
Production status: ✅ Proven on testnet3 with real devices and real blockchain
Last Updated: 2026-08-21 (v0.2 hybrid notes: build() + Kotlin broadcast)
Status: Production-ready (v0.2 hybrid architecture Aug 20-21)
Evidence: TXID 193c3c9e5287e13cc56e1401aed55de34db9a375312e052807aea060e58e3d96