Create a Basic Wallet
In this tutorial, the goal is to get to a place where a user can create, store, and access their Stellar account using an intuitive pincode encryption method. It assumes that you have already completed the Project Setup section.
View the setup boilerplate code on GitHub
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 Stellar 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 pin code → Enter pin code, click “OK” → App encrypts a new Stellar keypair secret key with pin code → 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 pin code.
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:
- bash
npm run generate
Call it stellar-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:
- SCSS
@import "../../global/style.scss";
:host {
display: block;
font-family: $font-family;
font-size: 15px;
.prompt-wrapper {
sidebar_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%;
sidebar_position: relative;
p {
margin-bottom: 10px;
}
input {
width: 100%;
margin: 0;
padding: 5px;
outline: none;
bsidebar_position: 1px solid black;
text-transform: uppercase;
&:focus {
border-color: blue;
}
}
}
.select-wrapper {
sidebar_position: relative;
display: inline-flex;
select {
border-color: blue;
padding: 0 10px;
min-width: 100px;
}
&:after,
&:before {
font-size: 12px;
sidebar_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;
bsidebar_position: 1px solid blue;
color: blue;
}
.submit {
margin-left: 10px;
}
}
}
Replace the prompt.tsx
contents with this.
- TSX
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: "stellar-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:
- bash
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 <stellar-prompt prompter={this.prompter} />
component elsewhere in our project. It's worth noting the variables available to us in the prompter
property.
- TypeScript
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:
- TypeScript
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 StellarAccount {
publicKey: string;
keystore: string;
}
@Component({
tag: "stellar-wallet",
styleUrl: "wallet.scss",
shadow: true,
})
export class Wallet {
@State() account: StellarAccount;
@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:
- JavaScript
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 stellar-wallet
project before running this string of commands:
- bash
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.
- TypeScript
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.
- Example
{
"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.
- Example
{
// ...
"_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.
- bash
npm i -D module-alias
- TypeScript
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 Stellar Account Class
- TypeScript
interface StellarAccount {
publicKey: string;
keystore: string;
}
interface
is just the TypeScript way of setting up a tidy typed class. StellarAccount
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.
- JavaScript
@Component({
tag: 'stellar-wallet',
styleUrl: 'wallet.scss',
shadow: true
})
export class Wallet {
@State() account: StellarAccount
@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 StellarAccount
class as well as a prompter
state with that Prompter
class from the stellar-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
- JavaScript
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.
- bash
mkdir -p src/services
touch src/services/{error,storage}.ts
error.ts
will look like this.
- TypeScript
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
.
- JavaScript
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.
- bash
# Install dependencies
npm i -D @capacitor/[email protected] @capacitor/[email protected]
# Initialize Capacitor
npx cap init
- bash
? App name Stellar 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/user/Stellar/docs-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 Stellar account data.
Set Up Event Handling
That’s everything we need for the componentWillLoad
event. On to the ./events/render.tsx
file.
- TSX
import { h } from "@stencil/core";
export default function render() {
return [
<stellar-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 stellar-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.
- TypeScript
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.
- bash
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
- TypeScript
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.
- bash
npm i -D copy-to-clipboard
Copy Secret
Next ./methods/copySecret.ts
- TypeScript
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
- TypeScript
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
.
- TypeScript
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 Stellar wallet key creation and storage Web Component! It's a solid foundation for a non-custodial wallet that relies on a simple pincode.