Skip to main content

Soul Names

Soul Names

Overview​

In this guide you will learn how to create a backend service based on the Masa Express Library that will integrate with the Masa SDK. We are using the Masa CLI for testing.

The complete code of this guide can be found here: Masa Express Examples Soul Name

This example implementation will fulfil the following requirements:

  • the service will be compatible with the Masa SDK. It will be able to log in to the endpoint generated by this service.
  • the service will store the Session information in Memory. If required any of the Supported Session Store Providers can be implemented and plugged into Masa Express.
  • the service will generate a basic image and Soulname Metadata that is compatible with OpenSea.
  • the service will persist the metadata in Arweave. The metadata can be persisted in other storage systems like IPFS, Filecoin, Cloud Services like Google Cloud or Amazon Web Services. Also storing on premise is possible if the endpoints are accessible from the internet.

Implementation​

To run this guide it is required to have two private keys:

  • An EVM compatible private key of the Authority Account that is allowed to sign Soulname transactions.
  • An Arweave compatible private key in the json format to store the image and metadata to arweave permanent storage.

Server Setup​

To create an endpoint that can be reached by the Masa SDK we need to create server. We are using express to run a service on node. The result of this service can be put into a docker, it can be run on AWS or using https://render.com. It is important to properly configure the cors configuration in case you want to make the endpoint accessible from the browser.

To enable soul names on the newly created service we need to add a soul name router and implement the /soul-name/store endpoint:

export const soulNameRouter: Router = express.Router();

soulNameRouter.post(
"/store",
async (request: Request, response: Response): Promise<void> => {
// your code goes here
}
);

app.use("/soul-name", soulNameRouter);

Here is the full server example:

import express, { Express, RequestHandler, Response, Router } from "express";
import {
MasaSessionMiddleware,
MasaSessionRouter,
sessionCheckHandler,
} from "@masa-finance/masa-express";
import cors from "cors";
import { storeSoulName } from "./store-soul-name";

const app: Express = express();

app.use(express.json());

// your session name
const sessionName = "my_fancy_session_name";
// never give this to someone!
const secret = "top_secret_1337";
// 30 days session expiration time
const ttl = 30 * 24 * 60 * 60;
// production, dev or undefined (will fall back to dev then)
const environment = "dev";
// the domain your session should be valid on
const domain = ".vitalik.org";
// custom namespace generated using: https://www.uuidtools.com/generate/v4
const sessionNamespace = "01bbc88d-3cd2-465f-8687-e0ea5e8b1231";

const sessionMiddleware: RequestHandler = MasaSessionMiddleware({
sessionName,
secret,
domain,
ttl,
environment,
});

app.use(
cors({
origin: domain,
credentials: true,
})
);

// session related
app.use(
"/session",
MasaSessionRouter({
sessionMiddleware,
sessionName,
sessionNamespace,
})
);

export const soulNameRouter: Router = express.Router();

soulNameRouter.use(sessionMiddleware);
soulNameRouter.use(sessionCheckHandler as never);

soulNameRouter.post("/store", (async (
request: Express.RequestSession,
response: Response
): Promise<void> => {
try {
response.json(
await storeSoulName(
request.body.soulName,
request.body.receiver,
request.body.duration,
request.body.network
)
);
} catch (error: unknown) {
if (error instanceof Error) {
console.error(`Soulname Error: ${error.message} ${error.stack}`);
}
}
}) as never);

app.use("/soul-name", soulNameRouter);

const port = process.env.PORT || 4000; // use whatever port you need

app.listen(port, () => {
console.log(`Express app listening at 'http://localhost:${port}'`);
});

for the next step see Soulname Validation and General Plumbing

Soulname Validation and General Plumbing​

To handle the request against the /soul-name/store endpoint we need to implement a soul name store handler. This handler will validate the soul name, check availability and finally generate metadata, store the metadata in immutable storage and return a signature so the user can use those information to mint a soul name.

It is most likely that you want to override the contracts being used by the SDK to connect to your own contracts. You can do that by using the contractOverrides object.

const wallet = new Wallet(WEB3_PRIVATE_KEY as string).connect(
new providers.JsonRpcProvider(SupportedNetworks[network]?.rpcUrls[0])
);

const contractOverrides: Partial<IIdentityContracts> = {};

// set soul store override
if (SOULSTORE_ADDRESS) {
contractOverrides.SoulStoreContract = SoulStore__factory.connect(
SOULSTORE_ADDRESS,
wallet
);
contractOverrides.SoulStoreContract!.hasAddress = true;
}

// set soul name override
if (SOULNAME_ADDRESS) {
contractOverrides.SoulNameContract = SoulName__factory.connect(
SOULNAME_ADDRESS,
wallet
);
contractOverrides.SoulNameContract!.hasAddress = true;
}

Then we need to create a new Masa instance to get access to the signing and storage methods.

const masa = new Masa({
wallet,
networkName: network,
verbose: true,
contractOverrides,
});

then we need to generate and store the metadata

const generateMetadataResult = await generateMetadata({
masa,
soulname: soulNameWithExtension,
});

finally we need to sign the result and return it to the user where the Masa SDK can pick it up and invoke the mint operation on the Blockchain.

const signResult = await masa.contracts.soulName.sign(
soulNameWithoutExtension,
soulNameLength,
duration,
// build metadataUrl
`ar://${generateMetadataResult.metadataTransaction?.id}`,
receiver
);

Here is the full handler example:

import {
IIdentityContracts,
Masa,
NetworkName,
SoulNameErrorCodes,
SoulNameMetadataStoreResult,
SoulNameResultBase,
SupportedNetworks,
} from "@masa-finance/masa-sdk";
import {
SoulName__factory,
SoulStore__factory,
} from "@masa-finance/masa-contracts-identity";
import { providers, Wallet } from "ethers";
import { generateMetadata } from "./generate-metadata";

const { WEB3_PRIVATE_KEY, SOULSTORE_ADDRESS, SOULNAME_ADDRESS } = process.env;

export const storeSoulName = async (
soulName: string,
receiver: string,
duration: number,
network: NetworkName
): Promise<SoulNameMetadataStoreResult | SoulNameResultBase> => {
// generate default result
const result: SoulNameResultBase = {
success: false,
message: "Unknown Error",
errorCode: SoulNameErrorCodes.UnknownError,
};

const wallet = new Wallet(WEB3_PRIVATE_KEY as string).connect(
new providers.JsonRpcProvider(SupportedNetworks[network]?.rpcUrls[0])
);

/**
* it is most likely that you want to override the contracts being used by the SDK
* to connect to your own contracts. You can do that by using the `contractOverrides`
* object
*/
const contractOverrides: Partial<IIdentityContracts> = {};

// set soul store override
if (SOULSTORE_ADDRESS) {
contractOverrides.SoulStoreContract = SoulStore__factory.connect(
SOULSTORE_ADDRESS,
wallet
);
contractOverrides.SoulStoreContract!.hasAddress = true;
}

// set soul name override
if (SOULNAME_ADDRESS) {
contractOverrides.SoulNameContract = SoulName__factory.connect(
SOULNAME_ADDRESS,
wallet
);
contractOverrides.SoulNameContract!.hasAddress = true;
}

// create a new masa instance and connect to the requested network
// make sure you use the private key of the authority account that has rights on the soulname contract
const masa = new Masa({
wallet,
networkName: network,
verbose: true,
contractOverrides,
});

// query the extension from the given contract
const extension = await masa.contracts.instances.SoulNameContract.extension();

// ensure name extension for metaData image
const soulNameWithExtension = `${soulName.replace(
extension,
""
)}${extension}`;
// scrub the soul name, so we have it without extension as well
const soulNameWithoutExtension = soulName.replace(extension, "");

// validate soul name and get the length
const {
isValid,
message,
length: soulNameLength,
} = masa.soulName.validate(soulNameWithoutExtension);

// check if valid soul name
if (!isValid) {
result.message = `Soulname ${soulNameWithExtension} is not valid: ${message}!`;
result.errorCode = SoulNameErrorCodes.SoulNameError;
console.error(result.message);
return result;
}

const isAvailable = masa.contracts.soulName.isAvailable(
soulNameWithoutExtension
);

// check if available
if (!isAvailable) {
result.message = `Soulname ${soulNameWithExtension} is not available!`;
result.errorCode = SoulNameErrorCodes.SoulNameError;
console.error(result.message);
return result;
}

const generateMetadataResult = await generateMetadata({
masa,
soulname: soulNameWithExtension,
});

if (generateMetadataResult?.success) {
// sign the soul name request
const signResult = await masa.contracts.soulName.sign(
soulNameWithoutExtension,
soulNameLength,
duration,
// build metadataUrl
`ar://${generateMetadataResult.metadataTransaction?.id}`,
receiver
);

if (!signResult) {
result.message = "Signing soul name failed!";
result.errorCode = SoulNameErrorCodes.CryptoError;
console.error(result.message);
return result;
}

return {
success: true,
errorCode: SoulNameErrorCodes.NoError,
message: "",
// image info
imageTransaction: generateMetadataResult.imageTransaction,
imageResponse: generateMetadataResult.imageResponse,
// metadata info
metadataTransaction: generateMetadataResult.metadataTransaction,
metadataResponse: generateMetadataResult.metadataResponse,
// signature
signature: signResult.signature,
authorityAddress: signResult.authorityAddress,
};
}

return generateMetadataResult;
};

for the next step see Metadata Generator

Metadata Generator​

Before we can generate the actual metadata object we must generate the image of the NFT that will be linked from within the metadata object. To do that we are using our own image generator from the Image Generator step. After we generated the image we need to hash and sign it to create metadata that can be verified later.

// generate the image and return its buffer here
const imageData: Buffer = await generateImage(soulname);
// hash the image
const imageHash: string = utils.keccak256(imageData);
// sign the hash using the authority key
const imageHashSignature = await signMessage(imageHash, masa.config.wallet);

In the next step we are going to generate metadata that is compatible with OpenSea. To achieve that we are implementing the ISoulName interface.

// create metadata
const metadata: ISoulName = {
description: "This is my 3rd Party soul name!" as any,
external_url: "https://my-fancy-app.org" as any,
name: soulname,
image: `ar://${imageTransaction.id}`,
imageHash,
imageHashSignature,
network: masa.config.networkName,
chainId: masa.config.network.chainId.toString(),
signature: "",
attributes,
};

After we create the NFT image and the metadata object we need to persist it. In this example we are using Arweave but IPFS or on-premise storage could be chosen as well.

Storing the image:

// create arweave transaction for the image
const imageTransaction = await masa.arweave.createTransaction(
{
data: imageData,
},
JSON.parse(ARWEAVE_PRIVATE_KEY as string)
);

// make sure we store the image as png
imageTransaction.addTag("Content-Type", "image/png");

// sign the arweave transaction
await masa.arweave.transactions.sign(
imageTransaction,
JSON.parse(ARWEAVE_PRIVATE_KEY as string)
);
const imageResponse = await masa.arweave.transactions.post(imageTransaction);

Storing the metadata:

// create arweave transaction for the metadata
const metadataTransaction = await masa.arweave.createTransaction(
{
data: Buffer.from(JSON.stringify(metadata as never)),
},
JSON.parse(ARWEAVE_PRIVATE_KEY as string)
);

// make sure we store the metadata as json
metadataTransaction.addTag("Content-Type", "application/json");

// sign tx
await masa.arweave.transactions.sign(
metadataTransaction,
JSON.parse(ARWEAVE_PRIVATE_KEY as string)
);

full metadata generator example:

import { generateImage } from "./image-generator";
import { utils } from "ethers";
import {
Attribute,
ISoulName,
Masa,
signMessage,
SoulNameErrorCodes,
SoulNameResultBase,
} from "@masa-finance/masa-sdk";
import Transaction from "arweave/node/lib/transaction";

const { ARWEAVE_PRIVATE_KEY } = process.env;

export const generateMetadata = async ({
masa,
soulname,
}: {
masa: Masa;
soulname: string;
}): Promise<
SoulNameResultBase & {
// image info
imageTransaction?: Transaction;
imageResponse?: {
status: number;
statusText: string;
data: unknown;
};
// metadata info
metadataTransaction?: Transaction;
metadataResponse?: {
status: number;
statusText: string;
data: unknown;
};
}
> => {
// generate default result
const result: SoulNameResultBase = {
success: false,
message: "Unknown Error",
errorCode: SoulNameErrorCodes.UnknownError,
};

// generate the image and return its buffer here
const imageData: Buffer = await generateImage(soulname);
// hash the image
const imageHash: string = utils.keccak256(imageData);
// sign the hash using the authority key
const imageHashSignature = await signMessage(imageHash, masa.config.wallet);

if (imageHashSignature) {
// create arweave transaction for the image
const imageTransaction = await masa.arweave.createTransaction(
{
data: imageData,
},
JSON.parse(ARWEAVE_PRIVATE_KEY as string)
);

// make sure we store the image as png
imageTransaction.addTag("Content-Type", "image/png");

// sign the arweave transaction
await masa.arweave.transactions.sign(
imageTransaction,
JSON.parse(ARWEAVE_PRIVATE_KEY as string)
);
const imageResponse = await masa.arweave.transactions.post(
imageTransaction
);

if (imageResponse.status !== 200) {
result.message = `Generating metadata image failed! ${imageResponse.statusText}`;
result.errorCode = SoulNameErrorCodes.ArweaveError;
console.error(result.message);
return result;
}

if (!masa.config.network) {
result.message = "Unable to evaluate current network!";
result.errorCode = SoulNameErrorCodes.NetworkError;
console.error(result.message);
return result;
}

const attributes: Attribute[] = [
{
trait_type: "Base",
value: "Starfish",
},
{
trait_type: "Eyes",
value: "Big",
},
{
trait_type: "Mouth",
value: "Surprised",
},
{
trait_type: "Level",
value: 5,
},
{
trait_type: "Stamina",
value: 1.4,
},
{
trait_type: "Personality",
value: "Sad",
},
{
display_type: "boost_number",
trait_type: "Aqua Power",
value: 40,
},
{
display_type: "boost_percentage",
trait_type: "Stamina Increase",
value: 10,
},
{
display_type: "number",
trait_type: "Generation",
value: 2,
},
];

// create metadata
const metadata: ISoulName = {
description: "This is my 3rd Party soul name!" as any,
external_url: "https://my-fancy-app.org" as any,
name: soulname,
image: `ar://${imageTransaction.id}`,
imageHash,
imageHashSignature,
network: masa.config.networkName,
chainId: masa.config.network.chainId.toString(),
signature: "",
attributes,
};

// sign metadata
const metadataSignature = await signMessage(
JSON.stringify(metadata, null, 2),
masa.config.wallet
);

if (metadataSignature) {
// place signature inside the metadata object
metadata.signature = metadataSignature;

// create arweave transaction for the metadata
const metadataTransaction = await masa.arweave.createTransaction(
{
data: Buffer.from(JSON.stringify(metadata as never)),
},
JSON.parse(ARWEAVE_PRIVATE_KEY as string)
);

// make sure we store the metadata as json
metadataTransaction.addTag("Content-Type", "application/json");

// sign tx
await masa.arweave.transactions.sign(
metadataTransaction,
JSON.parse(ARWEAVE_PRIVATE_KEY as string)
);

const metadataResponse = await masa.arweave.transactions.post(
metadataTransaction
);

// evaluate arweave results
if (metadataResponse.status !== 200) {
result.message = `Generating metadata failed! ${imageResponse.statusText}`;
result.errorCode = SoulNameErrorCodes.ArweaveError;
console.error(result.message);
return result;
}

return {
success: true,
errorCode: SoulNameErrorCodes.NoError,
message: "",
// image info
imageTransaction,
imageResponse,
// metadata info
metadataTransaction,
metadataResponse,
};
}
}

return result;
};

for the next step see Image Generator

Image Generator​

In the next step we are going to generate an image in the png format that will be stored and used as NFT image. It can be generated in any possible way and in different formats like jpg. You may like to delegate this step to some image generating service like DALL-E or Midjourney.

We are generating a background image with canvas and use the canvas-emoji library to stamp on the soul name on the image.

// load emoji lib
const canvasEmoji = new CanvasEmoji(context2D);

// draw soulname string with embedded emojis
canvasEmoji.drawPngReplaceEmoji({
emojiH: 24,
emojiW: 24,
fillStyle: "#000",
font: "32px serif",
x: 50,
y: 50,
text: soulName,
});

full image generator example:

import { Canvas, createCanvas } from "canvas";
import { CanvasEmoji } from "canvas-emoji";
import fs from "fs";

const width: number = 400;
const height: number = 400;

export const generateImage = async (soulName: string): Promise<Buffer> => {
// create canvas and ctx
const canvas: Canvas = createCanvas(width, height);
const context2D: CanvasRenderingContext2D = canvas.getContext("2d");

// draw background
context2D.fillStyle = "#fff";
context2D.fillRect(0, 0, width, height);

// load emoji lib
const canvasEmoji = new CanvasEmoji(context2D);

// draw soulname string with embedded emojis
canvasEmoji.drawPngReplaceEmoji({
emojiH: 24,
emojiW: 24,
fillStyle: "#000",
font: "32px serif",
x: 50,
y: 50,
text: soulName,
});

// create buffer from image
const buffer = canvas.toBuffer("image/png");

// output the file somewhere where we can see it
fs.mkdirSync("tmp", { recursive: true });
fs.writeFileSync(`tmp/${soulName}.png`, buffer);

return buffer;
};

Testing​

In this step we are using the Masa CLI to connect to the newly created endpoint and generate metadata for a new soul name.

Start Service​

$ source .env && yarn start

yarn run v1.22.19
$ nodemon ./src/server.ts
[nodemon] 2.0.22
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: ts,json
[nodemon] starting `ts-node ./src/server.ts`
Express app listening at 'http://localhost:4000'

Set endpoint​

$ masa settings set api-url http://localhost:4000/
__ __ ____ _ ___
| \/ | __ _ ___ __ _ / ___| | | |_ _|
| |\/| | / _` | / __| / _` | | | | | | |
| | | | | (_| | \__ \ | (_| | | |___ | |___ | |
|_| |_| \__,_| |___/ \__,_| \____| |_____| |___|

Key 'api-url' successfully set!

Login​

$ masa login
__ __ ____ _ ___
| \/ | __ _ ___ __ _ / ___| | | |_ _|
| |\/| | / _` | / __| / _` | | | | | | |
| | | | | (_| | \__ \ | (_| | | |___ | |___ | |
|_| |_| \__,_| |___/ \__,_| \____| |_____| |___|

Logging in
Signing:
'Welcome to 🌽Masa Finance!

Login with your soulbound web3 identity to unleash the power of DeFi.

Your signature is valid till: Thu, 18 May 2023 10:00:11 GMT.
Challenge: uxOJz9en7y6tWoaqC9wWJpmSab6ALcxA'

Signature: '0x3ad1a93d68f3abffc841a768d0ed3df9f5335c0cab0263e7d9d944473b5c813402c75c0944534ebe3a40edd20a88c715332c4564e1ff4f65fbdb68a8641d48df1b'

Logged in as:
Address: '0x8ba2D360323e3cA85b94c6F7720B70aAc8D37a7a'

output on the service side:

Express app listening at 'http://localhost:4000'
has challenge undefined
Session: {
"cookie": {
"originalMaxAge": 2592000000,
"expires": "2023-05-18T10:00:11.834Z",
"secure": false,
"httpOnly": false,
"domain": "localhost",
"path": "/",
"sameSite": "lax"
}
} has no challenge, rejected!
generated challenge! uxOJz9en7y6tWoaqC9wWJpmSab6ALcxA
has challenge uxOJz9en7y6tWoaqC9wWJpmSab6ALcxA

Create Soulname​

$ masa --network goerli soul-name create test 1

__ __ ____ _ ___
| \/ | __ _ ___ __ _ / ___| | | |_ _|
| |\/| | / _` | / __| / _` | | | | | | |
| | | | | (_| | \__ \ | (_| | | |___ | |___ | |
|_| |_| \__,_| |___/ \__,_| \____| |_____| |___|

User ID: '3b89ad7d-9977-5a47-b101-a58fb9f712ae'
Signer Address: '0x8ba2D360323e3cA85b94c6F7720B70aAc8D37a7a'
Network: 'goerli'


Writing metadata for 'test.soul'
Soul Name Metadata URL: 'ar://32Fo69-V04rc0lQ5D5S5zYRG66MVtxh5nMYysts8ZmE'
Waiting for transaction '0xb14d252ff20b1870509486508c1218fd3b02d152f9b524884e008f234d230f91' to finalize!
SoulName with ID: '29987' created.