Skip to content

Instantly share code, notes, and snippets.

@saltyskip
Last active February 5, 2026 15:21
Show Gist options
  • Select an option

  • Save saltyskip/122ca6b96cd2452b126d7c5ffa40c1bc to your computer and use it in GitHub Desktop.

Select an option

Save saltyskip/122ca6b96cd2452b126d7c5ffa40c1bc to your computer and use it in GitHub Desktop.
UniFFI vs C Bindings: Objects with Methods (sync + async) — side-by-side comparison

UniFFI vs C Bindings: Objects with Methods (sync + async)

Same functionality. Same runtime behavior. One is ~40 lines. The other is ~400+.


UniFFI — Rust (the only definition you write)

#[derive(uniffi::Object)]
pub struct WalletManager {
    service: WalletService,
}

#[uniffi::export(async_runtime = "tokio")]
impl WalletManager {
    #[uniffi::constructor]
    pub fn new(mnemonic: String) -> Result<Self, WalletError> {
        let service = WalletService::new(&mnemonic)?;
        Ok(Self { service })
    }

    // Sync method
    pub fn generate_address(
        &self,
        network: Network,
    ) -> Result<String, WalletError> {
        self.service.derive_address(network)
    }

    // Async method
    pub async fn get_balance(&self) -> Result<Vec<Balance>, WalletError> {
        self.service.fetch_balances().await
    }
}

#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum WalletError {
    #[error("Invalid mnemonic: {0}")]
    InvalidMnemonic(String),
    #[error("Network error: {0}")]
    NetworkError(String),
}

UniFFI — Swift (auto-generated, zero hand-written code)

// All of this is generated by UniFFI ✅

let wallet = try WalletManager(mnemonic: "senior awful soon ...")

// Sync - normal call
let address = try wallet.generateAddress(network: .bitcoin)

// Async - native Swift async/await
let balances = try await wallet.getBalance()

// Errors - typed Swift exceptions
do {
    let addr = try wallet.generateAddress(network: .solana)
} catch WalletError.InvalidMnemonic(let msg) {
    print("Bad mnemonic: \(msg)")
} catch WalletError.NetworkError(let msg) {
    print("Network failed: \(msg)")
}

// Memory - automatic. No manual free.
// wallet is dropped when Swift ARC releases it.

UniFFI — Kotlin (auto-generated, zero hand-written code)

// All of this is generated by UniFFI ✅

val wallet = WalletManager("senior awful soon ...")

// Sync - normal call
val address = wallet.generateAddress(network = Network.BITCOIN)

// Async - native Kotlin coroutine
val balances = wallet.getBalance()  // suspend function

// Errors - typed Kotlin exceptions
try {
    val addr = wallet.generateAddress(network = Network.SOLANA)
} catch (e: WalletError.InvalidMnemonic) {
    println("Bad mnemonic: ${e.message}")
} catch (e: WalletError.NetworkError) {
    println("Network failed: ${e.message}")
}

// Memory - automatic via Cleaner / pointers handled by generated code.

UniFFI total: 1 Rust file, ~40 lines, 0 unsafe, 0 manual free, native async/await on both platforms


C Bindings — C Header (you write + maintain)

// wallet_ffi.h
typedef void* WalletHandle;

// Constructor / destructor
WalletHandle wallet_new(const char* mnemonic, int32_t* out_error);
void wallet_free(WalletHandle handle);

// Sync method
char* wallet_generate_address(
    WalletHandle handle,
    int32_t network,
    int32_t* out_error
);

// Async method - callback-based
typedef void (*BalanceCallback)(
    const char* json_result,
    int32_t error_code,
    void* user_data
);
void wallet_get_balance(
    WalletHandle handle,
    BalanceCallback callback,
    void* user_data
);

// Memory cleanup
void string_free(char* s);
char* get_last_error(void);

C Bindings — Rust FFI exports (you also write)

// lib.rs - manual FFI layer
#[no_mangle]
pub extern "C" fn wallet_new(
    mnemonic: *const c_char,
    out_error: *mut i32,
) -> *mut WalletManager {
    let mnemonic = unsafe { CStr::from_ptr(mnemonic).to_string_lossy() };
    match WalletService::new(&mnemonic) {
        Ok(service) => {
            unsafe { *out_error = 0 };
            Box::into_raw(Box::new(WalletManager { service }))
        }
        Err(e) => {
            set_last_error(e.to_string());
            unsafe { *out_error = 1 };
            std::ptr::null_mut()
        }
    }
}

#[no_mangle]
pub extern "C" fn wallet_free(handle: *mut WalletManager) {
    if !handle.is_null() {
        unsafe { drop(Box::from_raw(handle)) };
    }
}

#[no_mangle]
pub extern "C" fn wallet_generate_address(
    handle: *mut WalletManager,
    network: i32,
    out_error: *mut i32,
) -> *mut c_char {
    let mgr = unsafe { &*handle };
    let net = match network {
        0 => Network::Bitcoin,
        1 => Network::Ethereum,
        _ => {
            set_last_error("Unknown network");
            unsafe { *out_error = 2 };
            return std::ptr::null_mut();
        }
    };
    match mgr.service.derive_address(net) {
        Ok(addr) => {
            unsafe { *out_error = 0 };
            CString::new(addr).unwrap().into_raw()
        }
        Err(e) => {
            set_last_error(e.to_string());
            unsafe { *out_error = 1 };
            std::ptr::null_mut()
        }
    }
}

#[no_mangle]
pub extern "C" fn wallet_get_balance(
    handle: *mut WalletManager,
    callback: BalanceCallback,
    user_data: *mut c_void,
) {
    let mgr = unsafe { &*handle };
    let ud = user_data as usize;
    RUNTIME.spawn(async move {
        let mgr = unsafe { &*(ud as *const WalletManager) };
        match mgr.service.fetch_balances().await {
            Ok(balances) => {
                let json = serde_json::to_string(&balances).unwrap();
                let c = CString::new(json).unwrap();
                callback(c.as_ptr(), 0, ud as *mut c_void);
            }
            Err(e) => {
                set_last_error(e.to_string());
                callback(std::ptr::null(), 1, ud as *mut c_void);
            }
        }
    });
}

C Bindings — Swift wrapper (you also write)

// WalletManager.swift - manual wrapper
class WalletManager {
    private let handle: OpaquePointer

    init(mnemonic: String) throws {
        var err: Int32 = 0
        guard let h = wallet_new(mnemonic, &err) else {
            throw WalletError.from(code: err)
        }
        self.handle = h
    }

    deinit {
        wallet_free(handle) // forget this = leak
    }

    // Sync
    func generateAddress(_ network: Network) throws -> String {
        var err: Int32 = 0
        let ptr = wallet_generate_address(handle, network.rawValue, &err)
        guard err == 0, let p = ptr else {
            throw WalletError.from(code: err)
        }
        defer { string_free(p) } // forget this = leak
        return String(cString: p)
    }

    // Async - bridge callback to async/await
    func getBalance() async throws -> [Balance] {
        try await withCheckedThrowingContinuation { continuation in
            let ctx = UnsafeMutablePointer<
                CheckedContinuation<[Balance], Error>
            >.allocate(capacity: 1)
            ctx.initialize(to: continuation)

            wallet_get_balance(
                handle,
                { jsonPtr, errCode, userData in
                    let cont = userData!.assumingMemoryBound(
                        to: CheckedContinuation<[Balance], Error>.self
                    ).pointee

                    if errCode != 0 {
                        cont.resume(throwing: WalletError.from(code: errCode))
                    } else {
                        let json = String(cString: jsonPtr!)
                        let data = json.data(using: .utf8)!
                        let balances = try! JSONDecoder().decode(
                            [Balance].self, from: data
                        )
                        cont.resume(returning: balances)
                    }
                    userData!.deallocate()
                },
                ctx
            )
        }
    }
}

C Bindings — Kotlin/JNI wrapper (you also write)

// WalletManager.kt - manual JNI wrapper
class WalletManager(mnemonic: String) : AutoCloseable {
    private var handle: Long

    init {
        val errBuf = IntArray(1)
        handle = nativeNew(mnemonic, errBuf)
        if (errBuf[0] != 0) {
            throw WalletError.from(errBuf[0], nativeGetLastError())
        }
    }

    // Sync
    fun generateAddress(network: Network): String {
        val errBuf = IntArray(1)
        val result = nativeGenerateAddress(handle, network.ordinal, errBuf)
            ?: throw WalletError.from(errBuf[0], nativeGetLastError())
        if (errBuf[0] != 0) {
            throw WalletError.from(errBuf[0], nativeGetLastError())
        }
        return result
    }

    // Async - bridge callback to coroutine
    suspend fun getBalance(): List<Balance> = suspendCancellableCoroutine { cont ->
        nativeGetBalance(handle, object : BalanceCallback {
            override fun onResult(json: String?, errorCode: Int) {
                if (errorCode != 0) {
                    cont.resumeWithException(
                        WalletError.from(errorCode, nativeGetLastError())
                    )
                } else {
                    val balances = Gson().fromJson(
                        json, Array<Balance>::class.java
                    ).toList()
                    cont.resume(balances)
                }
            }
        })
    }

    override fun close() {
        if (handle != 0L) {
            nativeFree(handle)
            handle = 0L
        }
    }

    protected fun finalize() { close() } // backup if close() not called

    // JNI declarations - you also write the C++ JNI bridge
    private external fun nativeNew(mnemonic: String, err: IntArray): Long
    private external fun nativeFree(handle: Long)
    private external fun nativeGenerateAddress(
        handle: Long, network: Int, err: IntArray
    ): String?
    private external fun nativeGetBalance(handle: Long, callback: BalanceCallback)
    private external fun nativeGetLastError(): String

    interface BalanceCallback {
        fun onResult(json: String?, errorCode: Int)
    }

    companion object {
        init { System.loadLibrary("wallet_ffi") }
    }
}
// wallet_jni.cpp - JNI bridge (you ALSO write)
extern "C" JNIEXPORT jlong JNICALL
Java_WalletManager_nativeNew(
    JNIEnv* env, jobject, jstring mnemonic, jintArray err
) {
    const char* m = env->GetStringUTFChars(mnemonic, nullptr);
    int32_t errCode = 0;
    auto handle = wallet_new(m, &errCode);
    env->ReleaseStringUTFChars(mnemonic, m);

    jint buf[1] = { errCode };
    env->SetIntArrayRegion(err, 0, 1, buf);
    return reinterpret_cast<jlong>(handle);
}

// ... repeat for every method ...

C bindings total: 5 files (header + Rust FFI + Swift + Kotlin + JNI C++), ~400+ lines, 8 unsafe blocks, manual free on every platform, callback-to-coroutine/continuation bridges for async

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment