Inspiration

In Nigeria, small businesses form the economic backbone of local communities, yet they often lack access to affordable, reliable payment infrastructure. Traditional POS terminals are expensive both for banks (due to hardware costs) and for merchants, who bear a silent ₦200 fee for each transaction.

This became personal to me when I observed the challenges faced by my parents while running a small supermarket in Lagos. Payment systems were costly, and overly dependent on internet connectivity and debit cards.

Initially, I explored integrating NFC payment functionality based on the ISO/IEC 14443 Type A/B standard, the same contactless protocol used by EMV contactless cards, into existing mobile banking applications. However, I quickly identified several critical technical and business barriers to this approach.

First, Apple restricts third-party access to the iPhone's Near Field Communication controller and Secure Element through their proprietary Host Card Emulation (HCE) framework, effectively limiting contactless payment implementations to Apple Pay. While Android offers more flexibility through Host-based Card Emulation, the fragmented implementation across device manufacturers creates reliability issues. Second, even with successful NFC integration, transactions would still require processing through existing card scheme networks (Mastercard, Visa, Verve), meaning merchants would continue paying the same interchange fees of ₦100-₦200 per transaction that the system was meant to reduce.

This led me to explore how mobile transfers actually work in Nigeria. I discovered that NIBSS Instant Payment (NIP) powers virtually all mobile banking transactions across the country. NIP enables fast, secure transfers with significantly lower transaction fees, typically ₦5, ₦20 compared to traditional POS charges of ₦100, ₦200 per transaction. This was a game-changer.

I asked myself: What if I could build a payment terminal that leverages Nigeria's existing NIP infrastructure without requiring physical cards, NFC implementations, or the constant internet connectivity that traditional POS systems depend on?

That question sparked the creation of METAL: an embeddable transmitter capable of sending and receiving transactional data between devices entirely offline, enabling secure, contactless payments without data costs or connectivity issues on the customer's end.

METAL isn't just a product it's a platform. It can be extended to interface with other hardware and, most importantly, it reduces the cost and complexity of accepting payments for micro and small businesses.

By enhancing proven wireless technologies and working around legacy infrastructure constraints, METAL is unlocking new possibilities for merchants in underserved regions. METAL matters because it democratizes offline, low-cost, contactless payments—making advanced payment capabilities a standard rather than a premium feature.

What it does

METAL is a low-cost, wireless payment transmitter that enables any smart device to make fast, secure, and contactless payments without relying on NFC, debit cards, or internet access on the customer's device.
Instead, METAL maintains its own connectivity through satellite, Wi-Fi, or SIM modules, ensuring transactions proceed seamlessly even when customers are offline.

To achieve this, METAL creates an end-to-end payment pipeline between banks, merchants, and customers.

1. Banks

The process begins with the bank. Each bank registers with METAL, which then provides a public API key. This key is integrated into the bank's mobile applications using the METAL SDK, which acts as the bridge that allows apps to communicate directly with METAL devices.

2. Merchants are at the heart of the problem METAL solves.

They use METAL devices to receive payments from customers.
Transactions are not only processed on the device but also synchronized with the merchant's dashboard.
The dashboard serves as a control center, providing insights into:

  • Transactions
  • Device locations
  • Business activity across all registered devices

3. Customers

Customers simply use their existing mobile banking applications.
Because banks integrate the METAL SDK and API key into their apps, these apps can securely interact with METAL devices.
All communication between METAL and customer devices is encrypted, ensuring end-to-end security for every transaction.

Why METAL is Different

METAL leverages cutting-edge RISC-V processing power with enhanced connectivity tailored for secure financial transactions. I've optimized the chip's native wireless protocol to achieve up to 1.5 Mbps encrypted transfers, delivering seamless communications with the performance and reliability demanded by modern payment systems. When a customer's device comes within range, METAL transmits a discovery signal that mobile devices detect and connect to, establishing a secure channel for transaction processing. Once the transaction is authorized, METAL automatically finalizes settlement through Nigeria's NIBSS Instant Payment (NIP) network. Unlike bloated multipurpose systems, METAL is purpose-built exclusively for payments, eliminating unnecessary background processes to maximize bandwidth and ensure lightning-fast financial transactions. This single-purpose efficiency, combined with optimized CPU utilization, delivers exceptional power performance. The system can operate for weeks on a single charge, making it ideal for merchants in power-constrained environments or remote locations where reliable payment processing is critical.

How I Built It

At the core of Metal is a multithreaded embedded application that runs on an ESP32 chip with 4MB of flash, 400KB of SRAM, and 160MHz of system clock that hosts four major components:

  • Keypad Control – handles user input from the matrix keypad, including key scanning and debouncing
  • Pipe Stream – manages cloud communication through HTTPS requests and responses
  • LCD Display – controls output to the LCD screen with status updates and user feedback
  • Jinx – the central coordinator that manages the transmission protocol, orchestrates data flow between components, and maintains overall system state These components run as individual threads built on FreeRTOS tasks, providing reliable multithreading with proper task scheduling and synchronization for embedded systems.

Pipe Stream Component

The Pipe Stream component was newly introduced to centralize all cloud communication, handling:

  • Network health checks
  • Firmware binary updates
  • Transaction data transmission

This component exclusively manages HTTPS communication with cloud services, providing a clean separation between local operations and network functionality.

Thread Implementation

    let thread1 = std::thread::Builder::new()
        .stack_size(7000)
        .spawn(move || {
            keypad_control( state_one, cols, rows);
        })
        .unwrap();
    let thread2 = std::thread::Builder::new()
        .stack_size(7000)
        .spawn(move || {
            pipe_stream(state_two);
        })
        .unwrap();
    let thread3 = std::thread::Builder::new()
        .stack_size(7000)
        .spawn(move || {
            lcd_display(lcd, state_three);
        })
        .unwrap();
    let state_two = state.clone();
    jinx(state_two).unwrap();

All threads except Jinx are allocated a 7KB stack size, as they handle lightweight operations and utilize thread parking when idle. Jinx runs on the main thread with the default stack allocation, reflecting its role as the primary controller that must remain continuously active to coordinate system operations.

To coordinate these four components effectively, Metal implements a thread synchronization system using Rust's conditional variables paired with mutex guards. This allows threads like Keypad Control, Pipe Stream, and LCD Display to remain dormant when not needed, while any active thread can wake others precisely when required. Jinx handles all wireless transmissions and remains persistently active to ensure reliable communication.

let mut can_type = state.can_type.lock().unwrap();
loop {
    if !*can_type {
        can_type = state.cond_var.wait(can_type).unwrap();
    } else {
        // Thread active - perform work
    }
    FreeRtos::delay_ms(2);
}

Inter-thread communication is managed through a shared MainState object, which is safely distributed across all threads using Rust's Atomic Reference Counting (Arc) mechanism.

pub struct MainState {
    pub can_type: Mutex<bool>,
    pub cond_var: Condvar,
    pub sent: AtomicBool,
    pub wifi_access: AtomicBool,
    pub msg: RwLock<heapless::Vec<char, 11>>,
    pub pipe: Mutex<Option<(MsgPipe, i64)>>,
    pub cond_var_pipe: Condvar,
    pub lcd_command: Mutex<Option<LCDCommand>>, 
    pub cond_var_lcd: Condvar,
}
let state = Arc::new(MainState::new(connected));
let state_one = state.clone();
let state_two = state.clone();
let state_three = state.clone();

Evolution: Separating LCD Display

Initially, the LCD display functionality ran within the Keypad Control thread. However, as Metal evolved, other components also needed display capabilities:

  • Pipe Stream – displaying payment confirmation messages
  • Jinx – showing transmission protocol status updates

To address this requirement, the LCD was moved to a dedicated thread with communication handled through an enum-based command system.

 pub enum LCDCommand {
    Message(bool),
    SetupTransmissionMsg(bool),
    HealthCheck((Healthly, bool)), 
    PaymentDone(bool),
    PreviousMsg(bool),
    PriceEntered(bool),
    ClearLastChar(bool),
    Character((char, bool)),
}

Sample of Command functions:

  • Message(_) → Display simple message and reset LCD
  • SetupTransmissionMsg(on_sent) → Show transmission status, optionally reset LCD
  • HealthCheck((health, on_sent)) → Display network health status, optionally reset LCD
  • PaymentDone(_) → Handle payment completion (placeholder for future functionality)
  • PreviousMsg(_) → Restore previously stored message
  • PriceEntered(on_sent) → Show customer message with buffered content, optionally reset LCD
  • ClearLastChar(on_sent) → Delete last character (backspace function), optionally reset LCD
  • Character((c, on_sent)) → Write new character to LCD (11 character limit), optionally reset LCD

This is a sample of a component sending display commands using thread-safe communication:

if let Ok(mut guard) = state.lcd_command.try_lock() {
    *guard = Some(LCDCommand::PriceEntered(*on_sent));
    state.cond_var_lcd.notify_one();
}

Metal Transmission Process Update

The Metal transmission process has been significantly enhanced to provide a more seamless and secure payment experience. These updates introduce intelligent device coordination and robust security measures.

Core Improvements

Unified Device Integration
A health check mechanism has been implemented to enable seamless integration between Metal device variants. This enhancement allows all devices to:

  • Process payments directly when network connectivity is available
  • Delegate payment processing to paired mobile devices when network access is unavailable

Intelligent Network Management
The system now automatically determines the optimal payment processing route based on real-time network availability, ensuring transaction reliability regardless of connectivity conditions.

Health Check Implementation

The linking process between Metal devices and mobile devices begins with a health check call initiated from the mobile device:

reader(for: health)

Then on METAL:

health
    .lock()
    .read(move |arg, _| {
        log::info!("READ_HEALTH");
        arg.set_value(&get_health(&state_health));
    });

fn get_health(state: &Arc<MainState>) -> [u8; 16] {
    let mut msg: [u8; 16] = [b'0'; 16];
    log::info!("Getting network status");
    let has_network_access = state.network_access.load(Ordering::Relaxed);

    if has_network_access {
        msg[0] = b'1'; // Network available
    }
    // msg[0] remains '0' if no network access

    msg
}

Health Check Protocol

The health check uses a simple but effective protocol:

  • Byte value 1 → Network access available – Metal device handles transaction
  • Byte value 0 → No network access – Mobile device processes transaction

Key Management

Each customer now receives a private key and a public key.

  • The public key and important data needed for transactions are stored securely on the user's device (via Keychain).

The server generates these keys.

pub fn generate_keys() -> Result<KeyPair, CryptError> {
    let server_private_key = SecretKey::random(&mut OsRng);
    let server_public_key = server_private_key.public_key();

    // To be able to use the key later, you should serialize it.
    let private_key_pem = server_private_key.to_pkcs8_pem(LineEnding::LF)
        .map_err(|e| CryptError::KeyGenerationError(format!("Failed to encode private key: {}", e)))?;
    // Define the file path
    let file_name = generate_randome_names(10)+".pem";
    // Write the key to the file
    fs::write(&file_name, private_key_pem.as_bytes()).map_err(|e| CryptError::FileWriteError(format!("Failed to write private key to file: {}", e)))?;
    let result = fs::read_to_string(&file_name).map_err(|e| CryptError::FileReadError(format!("Failed to read private key from file: {}", e)))?;
    fs::remove_file(&file_name).map_err(|e| CryptError::FileWriteError(format!("Failed to remove private key file: {}", e)))?;
    let public_key_pem = server_public_key.to_sec1_bytes();
    let public_key_base64 = base64::engine::general_purpose::STANDARD.encode(&public_key_pem);
    return Ok(KeyPair {
        private_key: result,
        public_key: public_key_base64,
        file_name: file_name,
    });
}


pub fn ecc_decrypt_key(key: &String, private_key: String) -> Result<String, CryptError> {
    let base64_string = key;
    let combined_data_bytes = general_purpose::STANDARD.decode(base64_string).map_err(|e| CryptError::KeyDecodingError(e.to_string()))?;

    let server_private_key = SecretKey::from_pkcs8_pem(&private_key).map_err(|e| CryptError::KeyDecodingError(format!("Failed to decode private key: {}", e)))?;

    // Separate ephemeral public key and sealed box
    const EPHEMERAL_PUBLIC_KEY_LEN: usize = 65;
    let ephemeral_public_key_bytes = &combined_data_bytes[0..EPHEMERAL_PUBLIC_KEY_LEN];
    let sealed_box_bytes = &combined_data_bytes[EPHEMERAL_PUBLIC_KEY_LEN..];

    // Perform key agreement
    let ephemeral_public_key = PublicKey::from_sec1_bytes(ephemeral_public_key_bytes).map_err(|e| CryptError::KeyDecodingError(format!("Failed to decode ephemeral public key: {}", e)))?;
    let shared_secret = diffie_hellman::<NistP256>(
        &server_private_key.to_nonzero_scalar(),
        ephemeral_public_key.as_affine()
    );

    // Derive symmetric key
    let hkdf = Hkdf::<Sha256>::new(None, shared_secret.raw_secret_bytes());
    let mut key = [0u8; 32];
    hkdf.expand(&[], &mut key).unwrap();
    let symmetric_key = aes_gcm::Key::<Aes256Gcm>::from_slice(&key);

    // Decrypt the sealed box
    let cipher = Aes256Gcm::new(symmetric_key);
    let nonce_len = 12;

    let nonce = aes_gcm::Nonce::from_slice(&sealed_box_bytes[0..nonce_len]);
    let ciphertext_bytes = &sealed_box_bytes[nonce_len..];

    let decrypted_bytes = cipher.decrypt(
        nonce,
        Payload {
            msg: ciphertext_bytes,
            aad: &[],
        },
    ).map_err(|e| CryptError::DecryptionError(format!("Decryption failed: {}", e)))?;

    let decrypted_message = String::from_utf8(decrypted_bytes).map_err(|e| CryptError::DecryptionError(format!("Failed to convert decrypted bytes to string: {}", e)))?;
    Ok(decrypted_message)
}

Improvements to the Transmission Pipeline

There is no longer an initial message exchange to verify the link before transmission between Metal and the mobile device.

Previously:

  • This step was time-consuming
  • It required the use of AES encryption, where a single shared key was used across the server, mobile device, and Metal
  • The same key was responsible for encrypting all data (messages and prices)

Now:

  • I use the public key stored in the Keychain to encrypt important information required for identification and verification of the user
  • The PUBLIC_ID of the user is attached to the encrypted data for additional validation
func handleEncrypt(msg: String, storeKey: String) -> String {
    guard let sec1Bytes = Data(base64Encoded: storeKey) else {
        fatalError("Invalid Base64 string")
    }
    guard let publicKey = try? P256.KeyAgreement.PublicKey(x963Representation: sec1Bytes) else {
        fatalError("Invalid public key data")
    }
    let ephemeralPrivateKey = P256.KeyAgreement.PrivateKey()
    let ephemeralPublicKey = ephemeralPrivateKey.publicKey

    let sharedSecret = try! ephemeralPrivateKey.sharedSecretFromKeyAgreement(with: publicKey)
    let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey(
        using: SHA256.self,
        salt: Data(), // in production a salt would be used
        sharedInfo: Data(), 
        outputByteCount: 32 // 32 bytes for AES-256
    )
    let now = Date()
    let unixTimestamp = now.timeIntervalSince1970
    let time = String(unixTimestamp).split(separator: ".")
    if time.count > 0 {
        let message = "\(msg)&"+time[0]
        let messageData = message.data(using: .utf8)!
        let sealedBox = try! AES.GCM.seal(messageData, using: symmetricKey)
        let combinedData = ephemeralPublicKey.x963Representation + sealedBox.combined!
        let base64String = combinedData.base64EncodedString()
        for d in combinedData {
            print(d)
        }
        print("combined data \(base64String)")
        return base64String
    }
    return ""
}

Encryption and Transmission Handling

A dedicated function now handles the encryption and then converts the result into a string format that can be easily transmitted.

I have improved the speed of the transmission process and introduced safeguards to ensure that no data is lost during communication.

In addition, each Metal device now has its own unique API key, enabling secure communication with the server. This also allows the server to identify and verify devices before processing any transaction, further strengthening the overall security of the system.

Because the transmission protocol works safest and fastest at 16 bytes per data send, a LinkedList Queue–like data structure was introduced to manage the transmission pipeline efficiently.

class node {
    var prev: node? = nil
    var next: node? = nil
    var value: String? = nil
}

class LinkedList {
    var head: node? = nil
    var tail: node? = nil
    func enqueue(node: node) {
        if tail == nil {
            tail = node
        } else {
            let end = tail
            end?.next = node
            node.prev = end
            tail = node
        }
        if head == nil {
            head = node
        }
    }
    func dequeue() -> Result<node,LinkedError> {
        if head == nil {
            return .failure(LinkedError.noDataFound)
        }
        let node = head!
        head = node.next
        return .success(node)
    }
    func chunk(data: String) {
        var current: String = ""
        for d in data {
            if current.count == 16 {
                let node = node()
                node.value = current
                self.enqueue(node: node)
                current = ""
            }
            current.append(d)
        }
        if current.count > 0 {
            let node = node()
            node.value = current
            self.enqueue(node: node)
        }
    }
}


enum LinkedError: Error {
    case noDataFound
}

This object makes the breaking down of data and the transmission process smoother and safer.

A wait mechanism was implemented on both Swift and Rust to ensure reliable communication between the mobile device and Metal. With this approach, the mobile device waits until Metal confirms that it has successfully received the data before proceeding. Once the acknowledgment is received, the system calls dequeue to fetch the next data node to be transmitted.

This guarantees that no data is skipped or lost during transmission and that every packet is delivered completely. While the implementation may sound time-consuming, the efficiency of the protocol combined with this mechanism ensures high performance. After extensive testing, I measured the average transmission time for a complete data exchange at 950 milliseconds, demonstrating both reliability and speed.

    notifier(notifed: …, error: (any Error)?) {
        if let error = error {
                print("Write failed: \(error.localizedDescription)")
            } else {
                writeData()
            }
    }
    private func writeData() {
        let data = linkedlist.dequeue()
        switch data {
        case .success(let node):
            …
            guard let value = node.value else {return}
            if value.contains("TAIL") {
                DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: {
                    self.message = "Transmission done! Metal processing payment..."
                    self.showMessage = true
                })
            }
            guard let send = value.data(using: .utf8) else {return}
            sender(send, for: writer, …)
        case .failure(let error):
            …
        }
    }

private func setupData() {
    list = LinkedList()
    guard let msg = KeychainManager.get(forKey: MSG_TO_SIGN) else {
        return
    }
    guard let key = KeychainManager.get(forKey: PUBLIC_KEY) else {
        return
    }
    guard let id = KeychainManager.get(forKey: PUBLIC_ID) else {
        return
    }
    let data = handleEncrypt(msg: msg, storeKey: key)
    let start = node()
    let mid = node()
    let end = node()
//        fc2d9730-148c-4e8f-b948-dc315170675a
    let ids = id.split(separator: "-")
    if ids.count < 5 {
        return
    }
    start.value = "HEAD"+ids[0]+ids[1]
    mid.value = "MID"+ids[2]+ids[3]
    end.value = "TAIL"+ids[4]
    list.enqueue(node: start)
    list.enqueue(node: mid)
    list.chunk(data: data)
    list.enqueue(node: end)
}

The HEAD and TAIL pointers help Jinx determine which bytes mark the start and end of the transmission process.

    encrypt_receiver
        .lock()
        .writer_and_reader(move |args| {
            let data = args.recv_data();
            let compare = data.iter().map(|x| x.clone() as char).collect::<String>();
            if compare.contains("HEAD") {
                if let Ok(mut conns) = conns_one.try_write() {
                    timer = Instant::now();
                    for conn in conns.iter_mut() {
                        if conn.id == args.desc().conn_handle() {
                            conn.data.clear();
                            let mut msg: heapless::Vec<u8, 16> = heapless::Vec::new();
                            data.iter().for_each(|f| {
                                msg.push(f.clone()).unwrap()
                            });
                            let _ = conn.data.push(msg).unwrap();
                        }
                    }
                } 
            } 
            else if compare.contains("TAIL") {
                if let Ok(mut conns) = conns_one.try_write() {
                    for conn in conns.iter_mut() {
                        if conn.id == args.desc().conn_handle() {
                            let mut msg: heapless::Vec<u8, 16> = heapless::Vec::new();
                            data.iter().for_each(|f| {
                                msg.push(f.clone()).unwrap()
                            });
                            let _ = conn.data.push(msg).unwrap();
                        }
                        let time = timer.elapsed().as_millis();
                        let send = conn.take_data();
                        if let Ok(mut pipe) = state_pipe.pipe.lock() {
                            *pipe = Some((send, time as i64));
                            state_pipe.cond_var_pipe.notify_one();
                        }
                        conn.data.clear();
                    }
                } 
            } else {
                if let Ok(mut conns) = conns_one.try_write() {
                    for conn in conns.iter_mut() {
                        if conn.id == args.desc().conn_handle() {
                            let mut msg: heapless::Vec<u8, 16> = heapless::Vec::new();
                            data.iter().for_each(|f| {
                                msg.push(f.clone()).unwrap()
                            });
                            let _ = conn.data.push(msg).unwrap();

                        }
                    }
                } 
            }
            args.notify(); // sends a message to mobile device to send next data...
        });

Once the data is completed (via the TAIL message), it is written to the pipeline data for pipe_stream to process and then send to the server.

HTTPS was introduced since the server is now deployed on DigitalOcean and can only be accessed via HTTPS through the Caddy proxy server.

let data = MetalPaymentRequest {
    customer_id: id, 
    device_id: DEVICE_ID,
    pipe: msg,
    encrypted_price: price,
    time: pipe.1
};
println!("Sending data to metal: {:?}", data);
let payload = serde_json::to_string_pretty(&data).unwrap();
let result: MetalPaymentResponse = post_request(&payload).unwrap();

pub fn post_request<T>(payload: &str) -> Result<T>
where
    T: DeserializeOwned,
{
    let connection = EspHttpConnection::new(&Configuration {
        use_global_ca_store: true,
        crt_bundle_attach: Some(esp_idf_svc::sys::esp_crt_bundle_attach),
        ..Default::default()
    })?;
    let headers = [
        ("content-type", "application/json"),
        ("content-length", &payload.len().to_string()),
        (XMINISTER_METAL_API_KEY, APK_KEY),
    ];
    let url = "https://ghost.flizzup-server.com/api/metal/payment";
    let mut client = Client::wrap(connection);
    let mut request = client.request(Method::Post, url, &headers)?;

    request.write_all(payload.as_bytes())?;
    let response = request.submit()?;
   ...
    let mut body = String::new();
    response.read_to_string(&mut body)?;
    Ok(serde_json::from_str(&body)?)
}

Once the data is sent to the server, a simulation is performed to model the process—primarily the timing—of how this information would be forwarded to NIBSS NIP for further processing.

Server and Website Integration

The server is still built using the Axum framework, but is now fully integrated with the Postgres database and fully hosted.

A new addition is the Metal Website, which was built using Svelte.
Although still a prototype, it was implemented to:

  • Act as a control center
  • Provide insights to businesses using Metal

Features for Businesses:

  • View all the devices they own
  • See which businesses are using which devices
  • Track which account numbers are assigned to each device
  • Access transactional data to monitor money transfers
  • Avoid the difficulty of distinguishing whether payments were done via card or transfers (since the current NIBSS dashboard only shows transfers)

Features for Banks:

  • A dedicated section where registered banks can view their API keys

End-to-End Ecosystem

These additional parts of Metal —the website, server, SDKs, and APIs — even though not directly part of the hardware device, are what make Metal a powerful, end-to-end ecosystem.

Challenges I ran into

  1. Working with no_std
    I initially used Embassy's async runtime to avoid FreeRTOS's dynamic memory allocation overhead, implementing custom state machines for efficient task management. However, I encountered intermittent, non-deterministic crashes that were extremely difficult to debug and trace. After consulting with the Rust ESP and Embassy communities, I learned these sporadic crashes are a known edge case that the communities are actively investigating.

Given my application's critical reliability requirements, I decided to refactor the codebase to use Rust's standard library with FreeRTOS's virtual thread implementation. This established approach provided the deterministic behavior my use case demanded. Through extensive testing, I achieved complete stability, confirming this was the right architectural choice for my specific requirements. Both approaches have their merits - Embassy excels in memory-constrained scenarios, while FreeRTOS threads offered the predictability I needed.

  1. Thread Synchronization and Data Flow
    Another significant challenge was managing thread synchronization and data flow integrity. Since everything was transmitted in small byte-sized chunks while multiple threads were running concurrently, I had to implement strict sequencing and locking mechanisms to prevent race conditions and data corruption.

  2. Physical Transmission Constraints
    I also faced physical challenges, such as calibrating the correct distance for initiating secure transmissions via Jinx, and optimizing the system for environments with poor or no internet connectivity.

Updated Challenges

  1. ECC Implementation
    Implementing Elliptic Curve Cryptography (ECC) was quite challenging, as many variations either did not work well in Rust or were simply too slow.

  2. Cross-Language Cryptography
    Because ECC keys were generated using Rust and then encrypted on the mobile side using Swift, finding a way to decrypt the data in Rust was difficult — since the two languages rely on different cryptographic libraries.

  3. Data Transmission Reliability
    The encrypted messages were quite long, and the normal transmission process struggled — with data frequently being lost.
    To resolve this, I replaced the old approach with a Linked List Queue–based data structure, which ended up working very well.

Accomplishments that I'm proud of

I'm proud of how deeply METAL controls the stack.

I built a system that is:

  1. Secure — with ECC, AES encryption, secure boot, flash encryption, and update mechanisms
  2. Affordable — entirely designed for Nigerian users
  3. Simple to use — despite the complexity underneath
  4. End-to-End — Metal provides all the utilities and APIs to get banks, merchants, and the Nigerian people started, with no manual integration or third-party APIs required

Most Importantly

Metal isn't reliant on Mastercard, Visa, or other foreign payment rails.
It integrates directly with NIBSS, enabling a truly local payment experience with:

  • Minimal fees
  • Full sovereignty

What I learned

The biggest lesson was clarity of purpose.
Metal came together quickly because I was solving a well-defined problem:
fast, affordable, and offline-capable mobile payments that work reliably even when the user's device has no internet access.

I learned that great solutions don't begin with adding features.
They begin with understanding the core problem, solving it simply, and building out only what's essential.

I learned that sometimes choosing the right encryption is not about selecting the most secure option, but about finding the right balance between efficiency and security.

I also discovered that embedded systems, while intimidating at first, become powerful once you embrace their constraints.
Working with low-level memory, static typing, and hardware-level communication in Rust gave me a whole new appreciation for efficient design.

What's next for Metal

I plan to establish a connection with NIBSS and leverage my SDK to publish my own application, driving adoption and encouraging the use of Metal. My next goal is enhancing Metal's security architecture. By implementing eFuse technology at the hardware level, I can create tamper-resistant security that burns permanent configurations into the chip, ensuring cryptographic keys and security policies cannot be altered or extracted even under physical attack. I'm exploring how Metal can be integrated across multiple sectors:

Toll gate systems and fuel stations for fast payments and automatic NIBSS reporting Healthcare facilities for secure patient payment processing and insurance verification Educational institutions for tuition payments, cafeteria transactions, and student ID systems Government services for tax payments, permit fees, and citizen service transactions Supply chain management for secure vendor payments and inventory tracking Microfinance institutions for loan disbursements and repayment collection

Each application would benefit from Metal's eFuse-hardened security, ensuring:

Immutable device identity and authentication Tamper-evident transaction logs Hardware-level encryption key protection Secure boot processes that prevent unauthorized firmware modifications

Share this project:

Updates