Files
Matt Hess fad6aca9c6 Fix doc inaccuracies across wallet-sync-spec, explorer-spec, ffi-scanner
Correct camelCase field names in FFI source comments, fix build script
  target description, bigint→number types for u64 params, address format
  values (legacy not CryptoNote), verify_rct binary return format, and
  stale line number references.
2026-02-27 01:19:38 +00:00

8.0 KiB

FFI CARROT Scanner Integration Reference

1. Overview

The Rust CARROT scanner performs the entire 7-step output scan in a single FFI call, eliminating the per-operation JS-to-Rust round-trips that made the pure-JS path slow.

Two entry points:

Function Purpose Step 1
salvium_carrot_scan_output Standard scan (incoming payments) X25519 ECDH: s_sr_unctx = k_vi * D_e
salvium_carrot_scan_internal Self-send scan (change/burns) Uses view_balance_secret directly as s_sr_unctx

Return codes: 1 = owned, 0 = not owned, -1 = error.

Both functions write their result as a Rust-allocated JSON buffer via the out_ptr/out_len output parameters. The caller must free this buffer with salvium_storage_free_buf.

2. FFI Function Signatures

salvium_carrot_scan_output

int32_t salvium_carrot_scan_output(
    const uint8_t *ko,                   // 1
    const uint8_t *view_tag,             // 2
    const uint8_t *d_e,                  // 3
    const uint8_t *enc_amount,           // 4
    const uint8_t *commitment,           // 5  (nullable)
    const uint8_t *k_vi,                 // 6
    const uint8_t *account_spend_pubkey, // 7
    const uint8_t *input_context,        // 8
    uintptr_t      input_context_len,    // 9
    uint64_t       clear_text_amount,    // 10
    const uint8_t *subaddr_data,         // 11
    uint32_t       n_sub,                // 12
    uint8_t      **out_ptr,              // 13
    uintptr_t     *out_len               // 14
);
# Name Type Size Notes
1 ko *const u8 32 Onetime output pubkey (compressed Ed25519)
2 view_tag *const u8 3 View tag bytes
3 d_e *const u8 32 Ephemeral pubkey
4 enc_amount *const u8 8 Encrypted amount (little-endian)
5 commitment *const u8 32 Pedersen commitment. Nullable -- pass null for coinbase outputs
6 k_vi *const u8 32 View incoming key (secret scalar)
7 account_spend_pubkey *const u8 32 Main account spend pubkey K_s
8 input_context *const u8 var TX input context (key image hashes)
9 input_context_len usize -- Byte length of input_context
10 clear_text_amount u64 8 Known amount (coinbase), or u64::MAX sentinel for "not provided"
11 subaddr_data *const u8 n*40 Binary subaddress map (see section 3)
12 n_sub u32 4 Number of entries in subaddr_data
13 out_ptr *mut *mut u8 8 Output: pointer to Rust-allocated JSON buffer
14 out_len *mut usize 8 Output: byte length of JSON buffer

salvium_carrot_scan_internal

Identical signature. Parameter 6 is view_balance_secret instead of k_vi:

# Name Type Size Notes
6 view_balance_secret *const u8 32 View balance secret (used directly as s_sr_unctx, no ECDH)

salvium_storage_free_buf

void salvium_storage_free_buf(uint8_t *buf_ptr, uintptr_t len);

Frees the Rust-allocated JSON result buffer. Must be called exactly once per successful scan (rc == 1).

3. Binary Formats

Subaddress map

Each entry is 40 bytes, tightly packed:

[32 bytes: spend pubkey] [4 bytes: major index LE] [4 bytes: minor index LE]

The buffer passed as subaddr_data must be exactly n_sub * 40 bytes. Pass n_sub = 0 with an empty/null buffer if there are no subaddresses.

Clear text amount sentinel

Pass 0xFFFFFFFFFFFFFFFF (u64::MAX) to indicate the amount is not known in clear text. The scanner will decrypt it from enc_amount using the derived mask. Pass the actual amount for coinbase outputs.

4. JSON Result Format

On success (rc == 1), the buffer at *out_ptr contains UTF-8 JSON:

{
  "amount": 1000000000,
  "mask": "hex64",
  "enote_type": 0,
  "shared_secret": "hex64",
  "address_spend_pubkey": "hex64",
  "subaddress_major": 0,
  "subaddress_minor": 0,
  "is_main_address": true
}
Field Type Description
amount u64 Decrypted amount in atomic units
mask hex string (64 chars) Commitment mask (32 bytes)
enote_type 0 or 1 0 = PAYMENT, 1 = CHANGE
shared_secret hex string (64 chars) Contextualized sender-receiver secret s_sr_ctx
address_spend_pubkey hex string (64 chars) Recovered address spend pubkey
subaddress_major u32 Major subaddress index (0 for main)
subaddress_minor u32 Minor subaddress index (0 for main)
is_main_address bool true if matched main account K_s

5. Memory Management

  1. Allocate out_ptr (8 bytes) and out_len (8 bytes) on the caller side.
  2. Call the scan function.
  3. If rc == 1: read *out_ptr and *out_len, copy/parse the JSON.
  4. Call salvium_storage_free_buf(*out_ptr, *out_len) to release the buffer.
  5. If rc == 0 or rc == -1: no buffer was allocated, do not call free.

Dart/Flutter example (dart:ffi):

final outPtr = calloc<Pointer<Uint8>>();
final outLen = calloc<IntPtr>();

final rc = scanOutput(ko, viewTag, dE, encAmount, commitment,
    kVi, accountSpendPubkey, inputContext, inputContextLen,
    clearTextAmount, subaddrData, nSub, outPtr, outLen);

if (rc == 1) {
  final json = outPtr.value.cast<Utf8>().toDartString(length: outLen.value);
  freeBuf(outPtr.value, outLen.value);
  final result = jsonDecode(json);
  // use result...
}

calloc.free(outPtr);
calloc.free(outLen);

6. Scanning Algorithm Reference

All hash operations use keyed Blake2b. Transcript format: [domain_len_byte][domain][data...].

Step Operation Domain separator Inputs Output
1 ECDH (standard only) -- k_vi, D_e s_sr_unctx (32 bytes)
2 View tag test "Carrot view tag" s_sr_unctx, input_context, Ko 3-byte tag; reject if mismatch
3 Contextualize secret "Carrot sender-receiver secret" s_sr_unctx, D_e, input_context s_sr_ctx (32 bytes)
4 Recover spend pubkey "Carrot key extension G", "Carrot key extension T" s_sr_ctx, commitment K^j_s = Ko - (k^o_g * G + k^o_t * T)
5 Address matching -- recovered pubkey, subaddress map Match against K_s or subaddress entries; reject if no match
6 Decrypt amount "Carrot encryption mask a" s_sr_ctx, Ko XOR 8-byte mask with enc_amount
7 Verify commitment "Carrot commitment mask" s_sr_ctx, amount, address, enote_type Derive mask, compute Pedersen C = mask*G + amount*H; try PAYMENT(0) then CHANGE(1)

For the internal (self-send) path, step 1 is skipped -- view_balance_secret is used directly as s_sr_unctx.

7. Integration Checklist

  • Build the Rust crate for each target:
    • Android: cargo ndk -t arm64-v8a -t armeabi-v7a -o jniLibs build --release
    • iOS: cargo lipo --release (or cargo build --target aarch64-apple-ios)
  • Load the shared library via dart:ffi (DynamicLibrary.open on Android, DynamicLibrary.process() on iOS with static linking)
  • Define FFI bindings matching the C signatures in section 2
  • Marshal inputs: hex-decode keys to Uint8List, pack subaddress map as 40-byte entries, set u64::MAX for unknown clear text amounts
  • Two-pass scan for each output:
    1. Call salvium_carrot_scan_output (standard path) with k_vi
    2. If rc == 0, call salvium_carrot_scan_internal with view_balance_secret to detect self-sends
  • Parse JSON result and map fields to your wallet model
  • Free the buffer with salvium_storage_free_buf after reading JSON
  • Handle errors (rc == -1): log and continue scanning remaining outputs

Source Files

File What
crates/salvium-crypto/src/ffi.rs:960-1153 FFI entry points
crates/salvium-crypto/src/carrot_scan.rs Scanner algorithm and CarrotScanResult
src/crypto/backend-ffi.js:628-722 JS reference implementation of marshalling