Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add sig verify to demo #150

Merged
merged 1 commit into from
Jul 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slimy-deers-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"demo-app": minor
---

Add an example signature verification api and an accompanying UI element to execute it
5 changes: 5 additions & 0 deletions .changeset/tall-games-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@burnt-labs/abstraxion-core": patch
---

Add a return type to SignArbSecp256k1HdWallet's `getAccounts` method
4 changes: 4 additions & 0 deletions apps/demo-app/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
module.exports = {
root: true,
extends: ["@burnt-labs/eslint-config-custom/next"],
rules: {
"no-console": ["error", { allow: ["warn", "error"] }],
"no-alert": "off",
},
};
1 change: 1 addition & 0 deletions apps/demo-app/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
8 changes: 7 additions & 1 deletion apps/demo-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@
},
"dependencies": {
"@burnt-labs/abstraxion": "workspace:*",
"@burnt-labs/abstraxion-core": "workspace:*",
"@burnt-labs/constants": "workspace:*",
"@burnt-labs/signers": "workspace:*",
"@burnt-labs/ui": "workspace:*",
"@cosmjs/amino": "^0.32.3",
"@cosmjs/cosmwasm-stargate": "^0.32.2",
"@heroicons/react": "^2.1.4",
"@keplr-wallet/cosmos": "^0.12.80",
"cosmjs-types": "^0.9.0",
"next": "^14.0.3",
"react": "^18.2.0",
"react-dom": "^18.2.0"
Expand All @@ -31,4 +37,4 @@
"tailwindcss": "^3.2.4",
"typescript": "^5.2.2"
}
}
}
205 changes: 205 additions & 0 deletions apps/demo-app/pages/api/check-signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { verifyADR36Amino } from "@keplr-wallet/cosmos";

// This import will need to change based on the chain you are confirming against.
import { testnetChainInfo } from "@burnt-labs/constants";
import { QueryGrantsResponse } from "cosmjs-types/cosmos/authz/v1beta1/query";

function isString(test: unknown): test is string {
return typeof test === "string";
}

/**
* Verify that the given XION signature corresponds to the given message and address.
*
* @param address - The address that is supposed to have signed the message.
* @param pubKey - The public key associated with the session address base64 encoded.
* @param messageString - The message that is supposed to have been signed.
* @param signature - The signature to verify against the message and address.
* @returns True if the signature is valid, false otherwise.
*/
export function verifyXionSignature(
address: string,
pubKey: string,
messageString: string,
signature: string,
): boolean {
const signatureBuffer = Buffer.from(signature, "base64");
const uint8Signature = new Uint8Array(signatureBuffer); // Convert the buffer to an Uint8Array
const pubKeyValueBuffer = Buffer.from(pubKey, "base64"); // Decode the base64 encoded value
const pubKeyUint8Array = new Uint8Array(pubKeyValueBuffer); // Convert the buffer to an Uint8Array
justinbarry marked this conversation as resolved.
Show resolved Hide resolved

return verifyADR36Amino(
"xion",
address,
messageString,
pubKeyUint8Array,
uint8Signature,
);
}

/**
* Verifies the Xion signature and grants for a given address, session address, public key,
* message string, and signature.
*
* @param address - The address to verify the grants for.
* @param sessionAddress - The session address to verify the grants for.
* @param pubKey - The public key associated with the session address base64 encoded.
* @param messageString - The message string to verify the signature against.
* @param signature - The signature to verify.
*
* @returns Promise<boolean> - A promise that resolves to true if the Xion signature and grants are valid,
* or false otherwise.
*/
export async function verifyXionSignatureAndGrants(
address: string,
sessionAddress: string,
pubKey: string,
messageString: string,
signature: string,
): Promise<boolean> {
const isValid = verifyXionSignature(
sessionAddress,
pubKey,
messageString,
signature,
);
if (!isValid) {
return false;
}

return verifyGrants(address, sessionAddress);
}

/**
* Verifies if grants have been given by a granter to a grantee.
*
* @param grantee - The address of the granter.
* @param granter - The address of the grantee.
*
* @returns Promise<boolean> - A promise that resolves to a boolean indicating whether ANY grants have been given.
*/
export async function verifyGrants(
granter: string,
grantee: string,
): Promise<boolean> {
const baseUrl = `${testnetChainInfo.rest}/cosmos/authz/v1beta1/grants`;
const url = new URL(baseUrl);
const params = new URLSearchParams({
grantee,
granter,
});
justinbarry marked this conversation as resolved.
Show resolved Hide resolved
url.search = params.toString();
const data = await fetch(url, {
cache: "no-store",
})
.then((response): Promise<QueryGrantsResponse> => response.json())
.catch((err) => {
console.error("Could not fetch grants info", err);
});
justinbarry marked this conversation as resolved.
Show resolved Hide resolved

return Boolean(data && data.grants.length > 0);
}

export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { query, method } = req;

if (method === "GET") {
const {
userSessionAddress,
userSessionPubKey,
metaAccountAddress,
message,
signature,
} = query;

const errors: string[] = [];
if (!userSessionAddress) {
errors.push("userSessionAddress is required");
}

if (!userSessionPubKey) {
errors.push("userSessionPubKey is required");
}

if (!metaAccountAddress && typeof metaAccountAddress === "string") {
errors.push("itemId is required");
}

if (!message && typeof message === "string") {
errors.push("message is required");
}

if (!signature && typeof signature === "string") {
errors.push("signature is required");
}
justinbarry marked this conversation as resolved.
Show resolved Hide resolved

if (errors.length > 0) {
res.status(400).json({ errors });
return;
}

// These aid TS type inference. You can pull in a more complex validation package to handle this like io-ts
if (!isString(userSessionAddress)) {
res
.status(400)
.json({ errors: ["userSessionAddress is required to be a string"] });
return;
}

if (!isString(userSessionPubKey)) {
res
.status(400)
.json({ errors: ["userSessionPubKey is required to be a string"] });
return;
}

if (!isString(metaAccountAddress)) {
res
.status(400)
.json({ errors: ["metaAccountAddress is required to be a string"] });
return;
}

if (!isString(message)) {
res.status(400).json({ errors: ["message is required to be a string"] });
return;
}

if (!isString(signature)) {
res
.status(400)
.json({ errors: ["signature is required to be a string"] });
return;
}

/*
* Confirming account "ownership" is a three-step process
* 1. Confirm the signature passed is valid for the temporary userSessionAddress
* 2. Pull any grant bestowed to the userSessionAddress on chain
* 3. Check that AT LEAST one grant exists of any type exists between the userSessionAddress and the metaAccountAddress
*
* NOTE: If the userSessionAccount has not submitted a transaction to the chain, the PubKey will be unknown.
* Therefore it must be passed as a api query parameter (In this example base64 encoded).
*
**/

const isValid = await verifyXionSignatureAndGrants(
metaAccountAddress,
userSessionAddress,
userSessionPubKey,
message,
signature,
);

res.status(200).json({
valid: isValid,
});
} else {
res.setHeader("Allow", ["GET"]);
res.status(405).end(`Method ${method} Not Allowed`);
}
}
38 changes: 4 additions & 34 deletions apps/demo-app/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ import {
useAbstraxionSigningClient,
useModal,
} from "@burnt-labs/abstraxion";
import { Button, Input } from "@burnt-labs/ui";
import { Button } from "@burnt-labs/ui";
import "@burnt-labs/ui/dist/index.css";
import type { ExecuteResult } from "@cosmjs/cosmwasm-stargate";
import { SignArb } from "../components/sign-arb.tsx";

const seatContractAddress =
"xion1z70cvc08qv5764zeg3dykcyymj5z6nu4sqr7x8vl4zjef2gyp69s9mmdka";

type ExecuteResultOrUndefined = ExecuteResult | undefined;

export default function Page(): JSX.Element {
// Abstraxion hooks
const { data: account } = useAbstraxionAccount();
Expand All @@ -28,7 +30,6 @@ export default function Page(): JSX.Element {
const [loading, setLoading] = useState(false);
const [executeResult, setExecuteResult] =
useState<ExecuteResultOrUndefined>(undefined);
const [arbitraryMessage, setArbitraryMessage] = useState<string>("");

const blockExplorerUrl = `https://explorer.burnt.com/xion-testnet-1/tx/${executeResult?.transactionHash}`;

Expand All @@ -43,14 +44,6 @@ export default function Page(): JSX.Element {
const oneYearFromNow = new Date();
oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1);

async function handleSign(): Promise<void> {
if (client?.granteeAddress) {
const response = await signArb?.(client.granteeAddress, arbitraryMessage);
// eslint-disable-next-line no-console -- We log this for testing purposes.
console.log(response);
}
}

async function claimSeat(): Promise<void> {
setLoading(true);
const msg = {
Expand Down Expand Up @@ -128,30 +121,7 @@ export default function Page(): JSX.Element {
LOGOUT
</Button>
) : null}
{signArb ? (
<div className="mt-10 w-full">
<h1 className="text-lg font-normal tracking-tighter text-white">
SIGN ARBITRARY MESSAGE
</h1>
<Input
className="ui-w-full ui-mb-4"
onChange={(e) => {
setArbitraryMessage(e.target.value);
}}
placeholder="Message..."
value={arbitraryMessage}
/>
<Button
disabled={loading}
fullWidth
onClick={() => {
void handleSign();
}}
>
Sign
</Button>
</div>
) : null}
{signArb ? <SignArb /> : null}
</>
) : null}
<Abstraxion
Expand Down
45 changes: 45 additions & 0 deletions apps/demo-app/src/components/blocking-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useState } from "react";
import { ArrowPathIcon } from "@heroicons/react/24/outline";

interface BlockingButtonProps {
text: string;

/**
* Function to execute when button is pressed
*/
onExecute(): Promise<void>;
}

export function BlockingButton({ text, onExecute }: BlockingButtonProps) {
const [loading, setLoading] = useState(false);

const handleClick = async () => {
try {
setLoading(true);
// Execute the supplied function
await onExecute();
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};

return (
<button
className="hover:ui-bg-neutral-100 min-w-full rounded-md bg-white px-4 py-2 text-black shadow-md"
onClick={() => {
void handleClick();
}}
disabled={loading}
>
{loading ? (
<p>
<ArrowPathIcon className="h-5 w-5 animate-spin content-center" />
</p>
) : (
text
justinbarry marked this conversation as resolved.
Show resolved Hide resolved
)}
</button>
);
}
Loading
Loading