Onboard Users

Learn how to map user accounts to blockchain accounts with USD and NGN tokens.


// npm install --save stellar-sdk simple-sha256

var StellarSdk = require('stellar-sdk');
const sha256 = require('simple-sha256')
const crypto = require("crypto")

// In this tutorial, we are going to map a normal account (e.g. user_abc), to blockchain account (e.g. GCSJ..) with access to its secret KeyboardEvent.DOM_KEY_LOCATION_LEFT

// We will use a mnemonic to help us to the mapping without depending on a database.
// The idea is to append the username to the mnemonic, generate a hash, and use that hash to get a private seed we can use to generate a blockchain account.
// 1. Get an app mnemonic.
// 2. Get a private seed from hash(mnemonic + user_account)
// 3. Use the private key in 2 to generate a new private_key, public_key pair for a new blockchain account.



// function mapping_username_to_blockchain_account
// Note that the same username will always produce the same (public_key, secret_key) pair. So you don't need to store the secret key or public key in any DB.
function mapping_username_to_blockchain_account(username) {

    // The app mnemonic seed we will use throughout. This should be kept server side and never exposed to end users.
    // You can generate your own mnemonics with the help of the following tool: https://iancoleman.io/bip39/#english
    // We recommend at least 24 words for the app mnemonic.
    // In prod, use environment variables to store this app mnemonic. Do not keep sensitive keys in code.

    const APP_MNEMONIC = "nasty school dial promote abstract defy damp minor lyrics carry unit aware special render subject fabric stand latin diesel once crumble hidden ancient exclude"
    // Create a blockchain keypair from a hash of the mnemonic plus username. The SDK expects a 32 byte buffer to be used as seed.
    // we hash with sha256.

    const salt = username + "NS;X=VB&=cD75AEg>kr" ; //this salt shouldn't change. but can be placed in an environment variable.
    let hash = sha256.sync(username + APP_MNEMONIC)
    //we could have stopped at using only sha256, but let's make it harder for anyone else trying to bruteforce by adding an scrypt hash in the mix. 
    // scrypt hashes are 'slower' to generate than sha256 alone, which makes them more than ideal for our scenario.
    let buffer = crypto.scryptSync(hash, salt, 32 , { N: Math.pow(2, 14) , r: 8, p: 1});

    return StellarSdk.Keypair.fromRawEd25519Seed(buffer)
}



function create_account_txn(sourceAccount, sourceAccountKeyPair, keypair) {

    // ISSUER_PUBLIC_KEY represents the public key of the issuer account. An issuer account usually represents a financial institution.
    // for the sake of this tutorial, note that the secret key for the issuer below is SA6R4KLE5MD4EKQYSNGLXBGQMAKHEPWZGRERA75H2CPO2KZ4627QQZGP.
    // do not store secret keys in code.
    // make sure this issuer account is funded.
    const ISSUER_PUBLIC_KEY = "GD6W7MMLY3EVMWSYSM6ZIWXWBKHSLUEL6UZOMLXYRMEE3LLUTZHQVR6B"


    transaction = new StellarSdk.TransactionBuilder(sourceAccount, {
        fee: StellarSdk.BASE_FEE,
        networkPassphrase: "Bantu Testnet",
    })
        .addOperation(
            StellarSdk.Operation.createAccount({
                destination: keypair.publicKey(),
                startingBalance: "10",
            }),
        )
        .addOperation(
            StellarSdk.Operation.changeTrust({
                asset: new StellarSdk.Asset("NGN", ISSUER_PUBLIC_KEY),
                source: keypair.publicKey()
            }),
        )
        .addOperation(
            StellarSdk.Operation.changeTrust({
                asset: new StellarSdk.Asset("USD", ISSUER_PUBLIC_KEY),
                source: keypair.publicKey()
            }),
        )
        // Wait a maximum of three minutes for the transaction
        .setTimeout(180)
        .build();

    transaction.sign(sourceAccountKeyPair)
    transaction.sign(keypair)

    return transaction;
}

// function onboard_blockchain_account_if_necessary
// funds a blockchain account, and adds trustlines for USD and NGN.
function onboard_blockchain_account_if_necessary(keypair) {
    // Here's the plan.
    // If the blockchain account is already funded and has the necessary USD and NGN trustlines, we do nothing.
    // If the blockchain account is not funded, we will fund it using native blockchain tokens from another FAUCET account, and add the necessary trustlines.


    // Do NOT USE THIS CODE in production. It cannot handle heavy load. To handle heavy load, we will need to add Channel accounts. We will tackle channel accounts in another tutorial.
    // Also, for production use, please do not store secret keys in the code.... use environment variables.


    // FAUCET_ACCOUNT_SECRET_KEY is the faucet account secret key that will be used to fund other unfunded blockchain accounts.
    // Make sure the faucet account is funded. 
    // In prod, use environment variables to store secret keys. Do not keep secret keys in code.
    const FAUCET_ACCOUNT_SECRET_KEY = "SC2W73J2UT4V2TJR43EL2B2T3RJFZQUYXY7ENABPS75MRALCGS6XPZ4F"
    // The public key of the faucet account above is GAVGVTAV7NF7WMQUDK3CHTWF3YIAOMVO4FNYJSYWE3GMYSSCMEHNPLQT
    let faucetKeyPair = StellarSdk.Keypair.fromSecret(FAUCET_ACCOUNT_SECRET_KEY)

    //now let's check if our blockchain account exists in the blockchain.

    var server = new StellarSdk.Server("https://expansion-testnet.bantu.network");

    server
        .loadAccount(faucetKeyPair.publicKey())
        // If the account is not found, surface a nicer error message for logging.
        .catch(function (error) {
            if (error instanceof StellarSdk.NotFoundError) {
                throw new Error("The faucet account " + faucetKeyPair.publicKey() + " does not exist!");
            } else return error;
        })
        .then(function (faucetAccount) {
            server.loadAccount(keypair.publicKey())
                .then(function (destinationAccount) {
                    //destination account already exists. Do nothing else.
                    console.log("Destination account already exists. Go check the account details of %s at %s/%s", keypair.publicKey(), "https://explorer.Bantu.stargate.is/account", keypair.publicKey())
                    throw new Error("Destination  account  " + keypair.publicKey() + " already exists");
                })
                .catch(function (error) {
                    if (!(error instanceof StellarSdk.NotFoundError)) {
                        //we are only interested in NotFoundErrors, which means the account doesn't exist yet on the blockchain.
                        throw error;
                    }

                    //keypair account does not exist. Create a create account txn and return.
                    let txn = create_account_txn(faucetAccount, faucetKeyPair, keypair)

                    console.log("Txn XDR is %s", txn.toXDR())
                    return server.submitTransaction(transaction);
                })
                .then(function (result) {
                    console.log("Success! Results:", result);
                    console.log("Go check the account details of %s at %s/%s", keypair.publicKey(), "https://explorer.Bantu.stargate.is/account", keypair.publicKey())
                })
                .catch(function (error) {
                    console.error("Something went wrong!", error);
                    // If the result is unknown (no response body, timeout etc.) we simply resubmit
                    // already built transaction:
                    // server.submitTransaction(transaction);
                });
        })

}



let username = "segun"; // change this username to whatever you want in this tutorial.

let userKeyPair = mapping_username_to_blockchain_account(username)

//note that the same username will always produce the same (public_key, secret_key) pair. So you don't need to store the secret key or public key in any DB.
console.log("User %s, has public_key = %s and secret_key = %s", username, userKeyPair.publicKey(), userKeyPair.secret())

onboard_blockchain_account_if_necessary(userKeyPair)


using System;
using System.Security.Cryptography;
using System.Text;
using stellar_dotnet_sdk; //Install-Package stellar-dotnet-sdk -Version 7.1.4
using BCrypt.Net;
using System.Net;
using System.Threading.Tasks;

namespace ConsoleApp5
{
    class Program
    {

        // function mapping_username_to_blockchain_account
        // Note that the same username will always produce the same (public_key, secret_key) pair. So you don't need to store the secret key or public key in any DB.
        
        static KeyPair MapUsernameToBlockchainAccount(String username)
        {
            // The app mnemonic seed we will use throughout. This should be kept server side and never exposed to end users.
            // You can generate your own mnemonics with the help of the following tool: https://iancoleman.io/bip39/#english
            // We recommend at least 24 words for the app mnemonic.
            // In prod, use environment variables to store this app mnemonic. Do not keep sensitive keys in code.

            String APP_MNEMONIC = "nasty school dial promote abstract defy damp minor lyrics carry unit aware special render subject fabric stand latin diesel once crumble hidden ancient exclude";
            // Create a blockchain keypair from a hash of the mnemonic plus username. The SDK expects a 32 byte buffer to be used as seed.
            // we are using two hashes here... first we hash with sha256, then we hash with bcrypt.

                var crypt = new SHA256Managed();
            string hash = String.Empty;
            byte[] crypto = crypt.ComputeHash(Encoding.UTF8.GetBytes(APP_MNEMONIC + username));

            byte[] seed = new byte[32];

            Buffer.BlockCopy(crypto, 0, seed, 0, 32);

            return KeyPair.FromSecretSeed(seed);


        }


        static Transaction CreateAccountTxn(stellar_dotnet_sdk.responses.AccountResponse sourceAccount, KeyPair sourceAccountKeyPair, KeyPair keypair)
        {

            // ISSUER_PUBLIC_KEY represents the public key of the issuer account. An issuer account usually represents a financial institution.
            // for the sake of this tutorial, note that the secret key for the issuer below is SA6R4KLE5MD4EKQYSNGLXBGQMAKHEPWZGRERA75H2CPO2KZ4627QQZGP.
            // do not store secret keys in code.
            // make sure this issuer account is funded.
            String ISSUER_PUBLIC_KEY = "GD6W7MMLY3EVMWSYSM6ZIWXWBKHSLUEL6UZOMLXYRMEE3LLUTZHQVR6B";


            Network network = new Network("Bantu Testnet");

            Network.Use(network);
            

            var transaction = new TransactionBuilder(sourceAccount)
                 .AddOperation(new CreateAccountOperation.Builder(keypair, "10").Build())
                 .AddOperation(new ChangeTrustOperation.Builder(Asset.CreateNonNativeAsset("NGN", ISSUER_PUBLIC_KEY)).SetSourceAccount(keypair).Build())
                 .AddOperation(new ChangeTrustOperation.Builder(Asset.CreateNonNativeAsset("USD", ISSUER_PUBLIC_KEY)).SetSourceAccount(keypair).Build())
                 .AddOperation(new ManageDataOperation.Builder("status", Encoding.UTF8.GetBytes("onboarded")).Build())
                 .Build();

            transaction.Sign(sourceAccountKeyPair);
            transaction.Sign(keypair);

           
        return transaction;
        }


        static async Task OnboardBlockchainAccountIfNecessary(KeyPair keypair)
        {

            // Here's the plan.
            // If the blockchain account is already funded and has the necessary USD and NGN trustlines, we do nothing.
            // If the blockchain account is not funded, we will fund it using native blockchain tokens from another FAUCET account, and add the necessary trustlines.


            // Do NOT USE THIS CODE in production. It cannot handle heavy load. To handle heavy load, we will need to add Channel accounts. We will tackle channel accounts in another tutorial.
            // Also, for production use, please do not store secret keys in the code.... use environment variables.


            // FAUCET_ACCOUNT_SECRET_KEY is the faucet account secret key that will be used to fund other unfunded blockchain accounts.
            // Make sure the faucet account is funded. 
            // In prod, use environment variables to store secret keys. Do not keep secret keys in code.
            String FAUCET_ACCOUNT_SECRET_KEY = "SC2W73J2UT4V2TJR43EL2B2T3RJFZQUYXY7ENABPS75MRALCGS6XPZ4F";
            // The public key of the faucet account above is GAVGVTAV7NF7WMQUDK3CHTWF3YIAOMVO4FNYJSYWE3GMYSSCMEHNPLQT

            KeyPair faucetKeyPair = KeyPair.FromSecretSeed(FAUCET_ACCOUNT_SECRET_KEY);

            var server = new Server("https://expansion-testnet.bantu.network");

            stellar_dotnet_sdk.responses.AccountResponse faucetAccount = null;

            try
            {
                faucetAccount = await server.Accounts.Account(faucetKeyPair.AccountId);
            }
            catch(stellar_dotnet_sdk.requests.HttpResponseException ex)
            {
                if(ex.StatusCode == ((int)HttpStatusCode.NotFound))
                {
                    Console.WriteLine("[ERROR] Faucet account {0} is unfunded.", faucetKeyPair.AccountId);
                    return;
                }

                throw ex;
            }


            stellar_dotnet_sdk.responses.AccountResponse keyPairAccount = null;

            try
            {
                keyPairAccount = await server.Accounts.Account(keypair.AccountId);
                //no exception. means already funded. Return.
                return;
            }
            catch (stellar_dotnet_sdk.requests.HttpResponseException ex)
            {
                if (ex.StatusCode == ((int)HttpStatusCode.NotFound))
                {
                   //account is unfunded... we can proceed.
                }
                else
                {
                    //unknown error.
                    throw ex;
                }

                
            }


            var txn = CreateAccountTxn(faucetAccount, faucetKeyPair, keypair);

            var result = await server.SubmitTransaction(txn);

            if (result.IsSuccess())
            {
                return;
            }
            else
            {
                Console.WriteLine("ERROR submitting txn {0} ; {1}", result.EnvelopeXdr, result.ResultXdr);
            }

        }
        


        static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");

            String username = "segun"; // change this username to whatever you want in this tutorial.

            var userKeyPair = MapUsernameToBlockchainAccount(username);
            //note that the same username will always produce the same (public_key, secret_key) pair. So you don't need to store the secret key or public key in any DB.

            Console.WriteLine("User {0}, has public_key = {1} and secret_key = {2}", username, userKeyPair.Address, userKeyPair.SecretSeed);

            OnboardBlockchainAccountIfNecessary(userKeyPair).Wait();
        }
    }
}

import org.stellar.sdk.*;
import org.stellar.sdk.requests.ErrorResponse;
import org.stellar.sdk.responses.AccountResponse;
import org.stellar.sdk.responses.SubmitTransactionResponse;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;

public class Main {

    // The app mnemonic seed we will use throughout. This should be kept server side and never exposed to end users.
    // You can generate your own mnemonics with the help of the following tool: https://iancoleman.io/bip39/#english
    // We recommend at least 24 words for the app mnemonic.
    // In prod, use environment variables to store this app mnemonic. Do not keep sensitive keys in code.
    final static String APP_MNEMONIC = "nasty school dial promote abstract defy damp minor lyrics carry unit aware special render subject fabric stand latin diesel once crumble hidden ancient exclude";
    final static String SALT = "NS;X=VB&=cD75AEg>kr";
    //Maximum trustline limit below. 
    final static String LIMIT = "922337203685.4775807";

    static KeyPair mapUsernameToKeyPair(String username) throws NoSuchAlgorithmException {
        String toDigest = username + APP_MNEMONIC + SALT;

        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] encodedhash = digest.digest(toDigest.getBytes(StandardCharsets.UTF_8));

        //you can add additional hashing algorithms like scrypt or bcrypt to make your mapping more resilient to brute-force attempts.

        //secret seeds require only 32 bytes
        return KeyPair.fromSecretSeed(Arrays.copyOfRange(encodedhash, 0, 32));

    }


    static boolean isKeyPairFunded(KeyPair keyPair) throws IOException {
        Server server = new Server("https://expansion-testnet.bantu.network");

        try {
            AccountResponse accountResponse = server.accounts().account(keyPair.getAccountId());
            //no error was thrown... which means account already exists.
            return true;
        } catch (ErrorResponse errorResponse) {

            if (errorResponse.getCode() == 404) {
                //account does not exist..
                return false;
            } else {
                System.out.println(errorResponse.getCode());
                throw errorResponse;
            }
        }


    }

    static void onboardKeyPair(KeyPair keyPair) throws IOException, AccountRequiresMemoException {

        Server server = new Server("https://expansion-testnet.bantu.network");

        final String FAUCET_ACCOUNT_SECRET_KEY = "SBXIBKL3FGP4I74X4HYLXFVCHU6C4TVM3FUTDMXV7R4WOYW32M6AYJHQ";

        KeyPair faucetKeyPair = KeyPair.fromSecretSeed(FAUCET_ACCOUNT_SECRET_KEY);

        if (!isKeyPairFunded(faucetKeyPair)) {
            System.out.println("faucet keypair is not funded. Aborting.");
            System.out.println(faucetKeyPair.getAccountId());
            return;

        }

        //the secret key for the following issuer account is: SD2WBQNZQRQJMGBXUZUKJYNLJOMS4DNI5X3AB5EDJXKIKKMEV3GZGESL
        //but you only need it's public key.
        final String ISSUER_PUBLIC_KEY = "GBOOHPZ7R2KYYACA23GSANG2EJ5ZXHE3JBSUQOILMNVQFLEDWZJTP3G5";

        KeyPair issuerKeyPair = KeyPair.fromAccountId(ISSUER_PUBLIC_KEY);

        if (!isKeyPairFunded(issuerKeyPair)) {
            System.out.println("issuer keypair is not funded. Aborting.");
            System.out.println(issuerKeyPair.getAccountId());
            return;

        }

        Network network = new Network("Bantu Testnet");


        AccountResponse faucetAccountResponse = server.accounts().account(faucetKeyPair.getAccountId());

        Transaction.Builder transactionBuilder = new Transaction.Builder(faucetAccountResponse, network);

        boolean keyPairAlreadyFunded = isKeyPairFunded(keyPair);

        if (!keyPairAlreadyFunded) {
            System.out.println("keypair has not been previously funded. Adding create account operation to fund it");
            //create keyPair
            CreateAccountOperation createAccountOperation = new CreateAccountOperation.Builder(keyPair.getAccountId(), "20").build();
            transactionBuilder.addOperation(createAccountOperation);
        }

        {
            //set email of keyPair

            ManageDataOperation manageDataOperation = new ManageDataOperation.Builder("email", "bogus@me.com".getBytes(StandardCharsets.UTF_8)).setSourceAccount(keyPair.getAccountId()).build();
            transactionBuilder.addOperation(manageDataOperation);
        }

        {
            //set phonenumber of keyPair

            ManageDataOperation manageDataOperation = new ManageDataOperation.Builder("phonenumber", "+234xxxxxxxxx".getBytes(StandardCharsets.UTF_8)).setSourceAccount(keyPair.getAccountId()).build();
            transactionBuilder.addOperation(manageDataOperation);
        }


        {
            // Add Naira trustline.

            ChangeTrustOperation changeTrustOperation = new ChangeTrustOperation.Builder(Asset.createNonNativeAsset("NGN", ISSUER_PUBLIC_KEY), LIMIT).setSourceAccount(keyPair.getAccountId()).build();
            transactionBuilder.addOperation(changeTrustOperation);

        }

        {
            // Add USD trustline.

            ChangeTrustOperation changeTrustOperation = new ChangeTrustOperation.Builder(Asset.createNonNativeAsset("USD", ISSUER_PUBLIC_KEY), LIMIT).setSourceAccount(keyPair.getAccountId()).build();
            transactionBuilder.addOperation(changeTrustOperation);

        }

        transactionBuilder.setTimeout(Transaction.Builder.TIMEOUT_INFINITE);

        //optional, let's add a memo.

        transactionBuilder.addMemo(Memo.text("test-memo"));
        transactionBuilder.setBaseFee(100);

        //let's build the transaction, sign and submit to horizon.

        Transaction transaction = transactionBuilder.build();

        transaction.sign(faucetKeyPair);
        transaction.sign(keyPair);

        //optionally log the transaction to be submitted to the blockchain

        System.out.println(transaction.toEnvelopeXdrBase64());

        SubmitTransactionResponse response = server.submitTransaction(transaction);

        if (response.isSuccess()) {
            System.out.println("Done");
        } else {
            System.out.println(response.getResultXdr().get());
        }


    }

    public static void main(String[] args) throws NoSuchAlgorithmException, IOException, AccountRequiresMemoException {
        System.out.println("Hello, World!");
        String user = "saint";
        KeyPair keyPair = mapUsernameToKeyPair(user);

        System.out.println("The keypair credentials for " + user + " are : ");
        System.out.println("===");

        System.out.println(new String(keyPair.getSecretSeed()));
        System.out.println(keyPair.getAccountId());

        System.out.println("===");

        onboardKeyPair(keyPair);
    }
}