Create a Basic Wallet

In this tutorial, the goal is to get to a place where a user can create, store, and access their Bantu account using an intuitive pincode encryption method.

User Flow

Because we've decided to build a non-custodial wallet, we don’t need to communicate with a server or database at all: everything can happen locally right on a user’s device. We’ll be doing all our work inside of src/components/wallet/, so head over there now. We’re going to use the StellarSdk.Keypair.random() from the StellarSdk to generate a new valid Bantu keypair. That's the easy part. The hard work will be storing that vital information in a secure yet accessible manner.

The user flow we're building will work like this: Click “Create Account” → UI modal popup asking for a pincode → Enter pincode, click “OK” → App encrypts a new Bantu keypair secret key with pincode → App saves encrypted secret to localStorage. On page reload, we’ll fetch the publicKey to “login” the user, but for any protected action such as “Copy Secret”, the modal will pop back up asking for the original pincode.

Create a Popup Modal

To start, let's look at at the popup modal. We’ll be mimicking the browser’s prompt functionality with our own new, more powerful component. First things first we should generate a new component:

npm run generate

Call it bantu-prompt, and deselect both test files leaving only the styling. Once you have that, open src/components/prompt/ and rename the .css file to .scss. Fill that style file with this:

@import "../../global/style.scss";

:host {
  display: block;
  font-family: $font-family;
  font-size: 15px;

  .prompt-wrapper {
    position: absolute;
    top: 0;
    left: 0;
    bottom: 0;
    right: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    align-content: center;
    min-height: 100vh;
    min-width: 100vw;
    background-color: rgba(black, 0.2);
    z-index: 1;
  }
  .prompt {
    background-color: white;
    padding: 20px;
    max-width: 350px;
    width: 100%;
    position: relative;

    p {
      margin-bottom: 10px;
    }
    input {
      width: 100%;
      margin: 0;
      padding: 5px;
      outline: none;
      border: 1px solid black;
      text-transform: uppercase;

      &:focus {
        border-color: blue;
      }
    }
  }
  .select-wrapper {
    position: relative;
    display: inline-flex;

    select {
      border-color: blue;
      padding: 0 10px;
      min-width: 100px;
    }

    &:after,
    &:before {
      font-size: 12px;
      position: absolute;
      right: 10px;
      color: blue;
    }
    &:after {
      content: "◀";
      top: calc(50% - 5px);
      transform: translate(0, -50%) rotate(90deg);
    }
    &:before {
      content: "▶";
      top: calc(50% + 5px);
      transform: translate(0, -50%) rotate(90deg);
    }
  }
  .actions {
    display: flex;
    justify-content: flex-end;
    margin-top: 10px;

    button {
      margin: 0;
      min-width: 50px;
    }
    .cancel {
      background: none;
      border: 1px solid blue;
      color: blue;
    }
    .submit {
      margin-left: 10px;
    }
  }
}

Replace the prompt.tsx contents with this.

import { Component, Prop, Element, Watch, h, State } from "@stencil/core";
import { defer as loDefer } from "lodash-es";

export interface Prompter {
  show: boolean;
  message?: string;
  placeholder?: string;
  options?: Array<any>;
  resolve?: Function;
  reject?: Function;
}

@Component({
  tag: "bantu-prompt",
  styleUrl: "prompt.scss",
  shadow: true,
})
export class Prompt {
  @Element() private element: HTMLElement;

  @Prop({ mutable: true }) prompter: Prompter;

  @State() private input: string;

  @Watch("prompter")
  watchHandler(newValue: Prompter, oldValue: Prompter) {
    if (newValue.show === oldValue.show) return;

    if (newValue.show) {
      this.input = null;

      if (newValue.options)
        this.input =
          this.input ||
          `${newValue.options[0].code}:${newValue.options[0].issuer}`;
      else
        loDefer(() => this.element.shadowRoot.querySelector("input").focus());
    } else {
      this.prompter.message = null;
      this.prompter.placeholder = null;
      this.prompter.options = null;
    }
  }

  componentDidLoad() {
    addEventListener("keyup", (e: KeyboardEvent) => {
      if (this.prompter.show)
        e.keyCode === 13
          ? this.submit(e)
          : e.keyCode === 27
          ? this.cancel(e)
          : null;
    });
  }

  cancel(e: Event) {
    e.preventDefault();

    this.prompter = {
      ...this.prompter,
      show: false,
    };
    this.prompter.reject(null);
  }

  submit(e: Event) {
    e.preventDefault();

    this.prompter = {
      ...this.prompter,
      show: false,
    };
    this.prompter.resolve(this.input);
  }

  update(e) {
    this.input = e.target.value.toUpperCase();
  }

  render() {
    return this.prompter.show ? (
      <div class="prompt-wrapper">
        <div class="prompt">
          {this.prompter.message ? <p>{this.prompter.message}</p> : null}

          {this.prompter.options ? (
            <div class="select-wrapper">
              <select onInput={(e) => this.update(e)}>
                {" "}
                {this.prompter.options.map((option) => (
                  <option
                    value={`${option.code}:${option.issuer}`}
                    selected={this.input === `${option.code}:${option.issuer}`}
                  >
                    {option.code}
                  </option>
                ))}
              </select>
            </div>
          ) : (
            <input
              type="text"
              placeholder={this.prompter.placeholder}
              value={this.input}
              onInput={(e) => this.update(e)}
            ></input>
          )}

          <div class="actions">
            <button
              class="cancel"
              type="button"
              onClick={(e) => this.cancel(e)}
            >
              Cancel
            </button>
            <button
              class="submit"
              type="button"
              onClick={(e) => this.submit(e)}
            >
              OK
            </button>
          </div>
        </div>
      </div>
    ) : null;
  }
}

One of the first things you’ll notice is the use of lodash-es. Let’s make sure we’ve got that imported before moving forward:

npm i -D lodash-es

There’s a lot going on in this file, but since this isn’t a Stencil tutorial we’ll skip the details. What this allows us to do it to use a <bantu-prompt prompter={this.prompter} /> component elsewhere in our project. It's worth noting the variables available to us in the prompter property.

export interface Prompter {
  show: boolean;
  message?: string;
  placeholder?: string;
  options?: Array<any>;
  resolve?: Function;
  reject?: Function;
}

The values we’ll be making most use of are those first three: show, message and placeholder. The last two—resolve and reject—are for promisifying the prompt so we can await a response before continuing with further logic. Don't worry: that statement will make more sense in a moment once we include this component in src/components/wallet/. Speaking of, let’s swing over to that component now.

We’ve got a lot of work to do in here so I’ll just paste the code in all its glory and we’ll walk through it block by block:

import { Component, State } from "@stencil/core";

import componentWillLoad from "./events/componentWillLoad";
import render from "./events/render";

import createAccount from "./methods/createAccount";
import copyAddress from "./methods/copyAddress";
import copySecret from "./methods/copySecret";
import signOut from "./methods/signOut";
import setPrompt from "./methods/setPrompt";

import { Prompter } from "@prompt/prompt";

interface BantuAccount {
  publicKey: string;
  keystore: string;
}

@Component({
  tag: "bantu-wallet",
  styleUrl: "wallet.scss",
  shadow: true,
})
export class Wallet {
  @State() account: BantuAccount;
  @State() prompter: Prompter = { show: false };
  @State() error: any = null;

  // Component events
  componentWillLoad() {}
  render() {}

  // Stellar methods
  createAccount = createAccount;
  copyAddress = copyAddress;
  copySecret = copySecret;
  signOut = signOut;

  // Misc methods
  setPrompt = setPrompt;
}

Wallet.prototype.componentWillLoad = componentWillLoad;
Wallet.prototype.render = render;

They say the beginning is a good place to start. Let’s do that:

import { Component, State } from "@stencil/core";

import componentWillLoad from "./events/componentWillLoad";
import render from "./events/render";

import createAccount from "./methods/createAccount";
import copyAddress from "./methods/copyAddress";
import copySecret from "./methods/copySecret";
import signOut from "./methods/signOut";
import setPrompt from "./methods/setPrompt";

import { Prompter } from "@prompt/prompt";

Just one import from a library we should already have installed.

The other relative path imports are all the events and methods we’ll create here in a moment. For now, just generate all those files in their appropriate directories. Ensure your console is at the root of the bantu-wallet project before running this string of commands:

mkdir -p src/components/wallet/{events,methods}
touch src/components/wallet/events/{componentWillLoad.ts,render.tsx}
touch src/components/wallet/methods/{createAccount,copyAddress,copySecret,signOut,setPrompt}.ts

Next we have this funky line which may seem like an npm team import, but is actually a fancy typescript module alias path.

import { Prompter } from "@prompt/prompt";

This allows us to avoid long error prone ../../../ paths and just use @{alias}/{path?}/{module}. In order to get this past both the linter and compiler we’ll need to modify a couple files.

First, modify the tsconfig.json file to include these values in the compilerOptions object.

{
  "compilerOptions": {
    "baseUrl": "./src",
    "paths": {
      "@prompt/*": ["components/prompt/*"],
      "@services/*": ["services/*"]
    },
    // ...
  },
  // ...
}

Next,modify the package.json file to include a _moduleAliases key at the root of the object.

{
  // ...
  "_moduleAliases": {
    "@prompt": "dist/collection/components/prompt",
    "@services": "dist/collection/services"
  }
}

Finally, install the module-alias package and add it to the top of the src/index.ts file.

npm i -D module-alias
import "module-alias/register";
export * from "./components";

Cool! With any luck we should be able to use these slick alias imports for the prompt and services directories now.

Create Bantu Account Class

interface BantuAccount {
  publicKey: string;
  keystore: string;
}

interface is just the TypeScript way of setting up a tidy typed class. BantuAccount will be our account class. It includes the publicKey for easy reference later in Horizon or Astrograph calls and the Top Secret keystore key containing the encrypted account secret cipher.

@Component({
  tag: 'bantu-wallet',
  styleUrl: 'wallet.scss',
  shadow: true
})
export class Wallet {
  @State() account: BantuAccount
  @State() prompter: Prompter = {show: false}
  @State() error: any = null

  ...
}

Pretty standard boring bits, setting up the @Component with its defining values and initializing with some @State and @Prop data. You can see we’re setting up an account state with our BantuAccount class as well as a prompter state with that Prompter class from the bantu-prompt we imported earlier. We’re initializing that prompter state with a show value of false so the prompt modal rendereth not initially.

Everything after this is the assignment of our imported events and methods from up above. Let’s begin with the ./events/componentWillLoad.ts

import { handleError } from "@services/error";
import { get } from "@services/storage";

export default async function componentWillLoad() {
  try {
    let keystore = await get("keyStore");

    this.error = null;

    if (keystore) {
      keystore = atob(keystore);

      const { publicKey } = JSON.parse(atob(JSON.parse(keystore).adata));

      this.account = {
        publicKey,
        keystore,
      };
    }
  } catch (err) {
    this.error = handleError(err);
  }
}

componentWillLoad is the Stencil way of pre-filling the state and prop values before actually rendering the component. In our case we’ll use this method to populate the account @State with the saved storage keyStore value if there is one. At first there won’t be, so we’ll come back to this once we’ve actually gone over how to create and save accounts. For now just know it’s here, and since you’re smart, I imagine you can already kind of see how it works.

“But wait!” you say, “What are the @services/error and @services/storage packages?” Fine, yes, we should go over those. Remember the module alias stuff from earlier? Well one was for @prompt and the other was for @services. Go ahead and create these two files and add them to the src/services directory.

mkdir -p src/services
touch src/services/{error,storage}.ts

error.ts will look like this.

import { get as loGet } from "lodash-es";

export function handleError(err: any) {
  return loGet(err, "response.data", loGet(err, "message", err));
}

Nothing fancy, just a clean little error handler we’ll make use of later when processing API requests.

Set Up Key Storage

Next is storage.ts.

import { Plugins } from "@capacitor/core";

const { Storage } = Plugins;

export async function set(key: string, value: any): Promise<void> {
  await Storage.set({
    key,
    value,
  });
}

export async function get(key: string): Promise<any> {
  const item = await Storage.get({ key });
  return item.value;
}

export async function remove(key: string): Promise<void> {
  await Storage.remove({ key });
}

You’ll notice a new package @capacitor/core. Let’s install and set that up.

# Install dependencies
npm i -D @capacitor/core @capacitor/cli

# Initialize Capacitor
npx cap init
? App name Bantu Wallet
? App Package ID (in Java package format, no dashes) com.wallet.stellar
? Which npm client would you like to use? npm
 Initializing Capacitor project in /Users/Desktop/Web/Clients/bantu/bantu-demo-wallet in 1.91ms


🎉   Your Capacitor project is ready to go!  🎉

Add platforms using "npx cap add":

  npx cap add android
  npx cap add ios
  npx cap add electron

Follow the Developer Workflow guide to get building:
https://capacitor.ionicframework.com/docs/basics/workflow

We’re not really making full use of Capacitor , but it is an amazing service so be sure and check it out! For now we just need it to make storing and retrieving our data a bit more stable.

This storage service is simply a key setter and getter helper for storing and retrieving data. We’ll use this for any persistent data we want to store. For now, that's our Bantu account data.

Set Up Event Handling

That’s everything we need for the componentWillLoad event. On to the ./events/render.tsx file.

import { h } from "@stencil/core";

export default function render() {
  return [
    <bantu-prompt prompter={this.prompter} />,
    this.account ? (
      [
        <div class="account-key">
          <p>{this.account.publicKey}</p>
          <button
            class="small"
            type="button"
            onClick={(e) => this.copyAddress(e)}
          >
            Copy Address
          </button>
          <button
            class="small"
            type="button"
            onClick={(e) => this.copySecret(e)}
          >
            Copy Secret
          </button>
        </div>,
      ]
    ) : (
      <button type="button" onClick={(e) => this.createAccount(e)}>
        Create Account
      </button>
    ),

    this.error ? (
      <pre class="error">{JSON.stringify(this.error, null, 2)}</pre>
    ) : null,
    this.account ? (
      <button type="button" onClick={(e) => this.signOut(e)}>
        Sign Out
      </button>
    ) : null,
  ];
}

It looks messy, but it’s actually a pretty simple .tsx file rendering out our DOM based off a series of conditional values. You can see we’re including the bantu-prompt component, and setting the prompter prop to our this.prompter state. We then have a ternary operation toggling between a Create Account button and a basic account UI. If this.account has a truthy value, we’ll print out the account’s publicKey along with some interaction buttons. If this.account is falsey, we’ll print out a singular Create Account button connected to, you guessed it, the createAccount method. After that logic, we print out an error if there is one, and finally a Sign Out button if there’s an account to sign out of. Those are the two Wallet @Component events.

Create Methods

Let’s look at the methods now beginning with the ./methods/createAccount.ts file.

import sjcl from "@tinyanvil/sjcl";
import { Keypair } from "stellar-sdk";

import { handleError } from "@services/error";
import { set } from "@services/storage";

export default async function createAccount(e: Event) {
  try {
    e.preventDefault();

    const pincode_1 = await this.setPrompt("Enter a keystore pincode");
    const pincode_2 = await this.setPrompt("Enter keystore pincode again");

    if (!pincode_1 || !pincode_2 || pincode_1 !== pincode_2)
      throw "Invalid pincode";

    this.error = null;

    const keypair = Keypair.random();

    this.account = {
      publicKey: keypair.publicKey(),
      keystore: sjcl.encrypt(pincode_1, keypair.secret(), {
        adata: JSON.stringify({
          publicKey: keypair.publicKey(),
        }),
      }),
    };

    await set("keyStore", btoa(this.account.keystore));
  } catch (err) {
    this.error = handleError(err);
  }
}

Aha! Finally something interesting. This method forms the meat of our component. Before we dive into it, though let’s install the missing @tinyanvil/sjcl package.

npm i -D @tinyanvil/sjcl

Create an Account

Essentially all we’re doing is making a request to create an account, which triggers the Prompt modal to ask for a pincode. That pincode will be used in the sjcl.encrypt method to encrypt the secret key from the Keypair.random() method. We set the this.account with the publicKey, which encrypted the keystore cipher, and now we're storing that cipher in base64 format in localStorage via our set('keyStore') method for easy retrieval when the browser reloads. We could also encode that cipher into a QR code or a link to share with other devices. Since it requires the pincode that encrypted cipher, it's as secure as the pincode you encrypt it with.

Copy Address

Now that we’ve created an account, there are three more actions we'll enable: copyAddress, copySecret, and signOut.

First ./methods/copyAddress.ts

import copy from "copy-to-clipboard";

export default async function copyAddress(e: Event) {
  e.preventDefault();
  copy(this.account.publicKey);
}

Well there you go, the easiest code you’ll see all day. Just copy the publicKey from the this.account object to the clipboard. Before we jump though don’t forget to install that copy-to-clipboard package.

npm i -D copy-to-clipboard

Copy Secret

Next ./methods/copySecret.ts

import sjcl from "@tinyanvil/sjcl";
import copy from "copy-to-clipboard";

import { handleError } from "@services/error";

export default async function copySecret(e: Event) {
  try {
    e.preventDefault();

    const pincode = await this.setPrompt("Enter your keystore pincode");

    if (!pincode) return;

    this.error = null;

    const secret = sjcl.decrypt(pincode, this.account.keystore);
    copy(secret);
  } catch (err) {
    this.error = handleError(err);
  }
}

You may not actually include this in your production wallet, but for now it's a simple demonstration of how to programmatically gain access to the secret key at a later date for making payments, creating trustlines, etc. It’s essentially the createAccount in reverse: it asks for the pincode to decrypt the keystore which, once decrypted, we copy into the clipboard.

Sign Out

Finally ./methods/signOut.ts

import { remove } from "@services/storage";
import { handleError } from "@services/error";

export default async function signOut(e: Event) {
  try {
    e.preventDefault();

    const confirmNuke = await this.setPrompt(
      "Are you sure? This will nuke your account",
      "Enter NUKE to confirm",
    );

    if (!confirm || !/nuke/gi.test(confirmNuke)) return;

    this.error = null;

    await remove("keyStore");
    location.reload();
  } catch (err) {
    this.error = handleError(err);
  }
}

It’s important to allow users to nuke their account, but we need to be careful to confirm that action with our faithful setPrompt. Once they opt to “NUKE” the account we can remove the keyStore and reload the app.

Set Prompt

Speaking of setPrompt the last method in our wallet.ts file is ./methods/setPrompt.ts.

export default function setPrompt(
  message: string,
  placeholder?: string,
  options?: Array<any>,
): Promise<string> {
  this.prompter = {
    ...this.prompter,
    show: true,
    message,
    placeholder,
    options,
  };

  return new Promise((resolve, reject) => {
    this.prompter.resolve = resolve;
    this.prompter.reject = reject;
  });
}

In setPrompt, we see how the prompt state is set, and how the Promise is set up to allow us to wait on the prompt whenever we call this method. It’s actually pretty slick, and it might be worth looking back at the src/components/prompt/prompt.tsx to see how the resolve and reject functions get called. It’s not central to our wallet creation, but it’s a pretty handy little component that will serve us well in the future as we continue to request input from the user.

That’s it folks! Restart the server with npm start and you’ve got a perfectly legitimate, minimal Bantu wallet key creation and storage Web Component! It's a solid foundation for a non-custodial wallet that relies on a simple pincode.

Last updated