skalenetwork / docs.skale.space

https://docs.skale.space/
MIT License
1 stars 2 forks source link

Using Invisible Signers #28

Closed manuelbarbas closed 5 months ago

manuelbarbas commented 11 months ago

Creating a seamless user onboarding experience is essential for the success of any dApp. A well-designed onboarding process can increase user retention and foster a positive first impression, which is crucial for encouraging users to come back for more.

Thanks to the zero gas fees nature of the SKALE chains projects can perform transactions on behalf of the users without compromising the company sustainability by covering huge gas fees costs.

In order to achieve the described above, the application can generate on the background a wallet for each user, distribute the free gas token to it and store it on the backend. Every time a user performs a transaction, the background wallet signs the transaction without the user having idea he just made a on-chain transaction.

Implementation Example

This codebase uses the Typescript language along with the Viem library to showcase a proof of concept on how to utilize background signers within an API or Server based environment.

This example also uses a sticky session per userId meaning that the randomly generated accounts are mapped 1:1 with a userId. This will persist only for the duration of the service liftetime. On application crash or restart new wallets will be created. To resolve these types of issues you can encrypt the private keys and store them in something like Redis to make a more sophisticated service that would also allow for multiple AZ usage.

1- Custodian

import { initializeCustodian } from "./utils";
import { createClient } from "./utils";
import { CUSTODIAN_PRIVATE_KEY, WSS_URL } from "./config";
import { parseEther } from "viem";

const DEFAULT_FILL_UP_VALUE: bigint = parseEther("0.00000002");

class Custodian {
    #nonce = 0;
    #custodian;
    #client;

    constructor() {
        this.#custodian = initializeCustodian(CUSTODIAN_PRIVATE_KEY as `0x${string}`);
        this.#client = createClient(WSS_URL);
    }

    public get custodian() {
        return this.#custodian;
    }

    public get client() {
        return this.#client;
    }

    public async isValidCustodian() {
        const balance = await this.#client.getBalance({
            address: this.#custodian.account.address
        });

        if (balance < parseEther("0.00005")) {
            throw new Error("Custodian Balance must be > 0.00005");
        }

        this.#nonce = await this.#client.getTransactionCount({
            address: this.#custodian.account.address
        });
    }

    public async distribute(to: `0x${string}`) {
        const hash = await this.#custodian.sendTransaction({
            to,
            value: DEFAULT_FILL_UP_VALUE,
            nonce: this.#nonce++
        });
        const tx = await this.#client.waitForTransactionReceipt({
            hash
        });
    }
}
export default new Custodian();

2- Background Signers

import { WalletClient, getAddress, parseAbi } from "viem";
import Custodian from "./custodian";
import { createSigner } from "./utils";
import { skaleChaosTestnet } from "viem/chains";
import { Contract } from "./contract";

class BackgroundSigners {
    #custodian: typeof Custodian;
    #signers: {[key: string]: WalletClient} = {};

    constructor() {
        this.#custodian = Custodian;
    }

    public async getUser(userId: string) {
        if (this.#signers[userId] === undefined) {
            const signer = createSigner();
            this.#signers[userId] = signer;
            await this.#custodian.distribute(signer.account.address);

        }

        return this.#signers[userId].account?.address as `0x${string}`;
    }

    public async remove(userId: string) {
        const account = this.#signers[userId].account;
        if (!account) return;
        this.#signers[userId].sendTransaction({
            to: this.#custodian.custodian.account.address,
            value: BigInt(1),
            type: "legacy",
            account,
            chain: skaleChaosTestnet
        });
    }

    public async backgroundSignerAction(userId: string, args: any[], functionName: "mint" | "burn") {
        const account = this.#signers[userId].account;
        if (!account) throw new Error("Account Not Found");

        await this.#signers[userId].writeContract({
            abi: Contract.abi,
            address: getAddress(Contract.address),
            functionName,
            args,
            account,
            chain: skaleChaosTestnet
        })
    }
}

export default new BackgroundSigners();

3- API

import { Router } from "express";
import BackgroundSigners from "./background_signers";
import { parseEther } from "viem";

const router = Router();

router.post("/mint", async (req, res) => {
    const userId: string = req.body.userId;
    const address = await BackgroundSigners.getUser(userId);

    try {
        await BackgroundSigners.backgroundSignerAction(userId, [address, parseEther("1")], "mint");
        return res.status(200).send("Minted Successfully");
    } catch (err) {
        return res.status(500).send("Error Minting");
    }
});

router.post("/burn", async (req, res) => {
    const userId: string = req.body.userId;
    const address = await BackgroundSigners.getUser(userId);

    try {
        await BackgroundSigners.backgroundSignerAction(userId, [parseEther("1")], "burn");
        return res.status(200).send("Burned Successfully");
    } catch (err) {
        return res.status(500).send("Error Burning");
    }
});

export default router;
manuelbarbas commented 9 months ago

What is: Wallet Language: Javascript/Typescript, C# Target: Web, Unity