Skip to content

Commit

Permalink
add support for backend transactions sent as backend wallet (#200)
Browse files Browse the repository at this point in the history
* refactor backend request signatures; allow for backend wallet tx

* add backend-to-backend examples

* make expiration time optional for account signatures

* update readmes

* add changeset

* upgrade thirdweb sdk

* upgrade dependencies

* update admin account signature format

* upgrade dependencies

* run linter

* update example

* add react changeset
  • Loading branch information
alecananian authored Jan 7, 2025
1 parent ee761e5 commit e1154ae
Show file tree
Hide file tree
Showing 31 changed files with 2,693 additions and 1,786 deletions.
5 changes: 5 additions & 0 deletions .changeset/serious-dancers-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@treasure-dev/tdk-react": patch
---

Ensure initial user login occurs on provided default chain
5 changes: 5 additions & 0 deletions .changeset/thin-buses-sort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@treasure-dev/tdk-core": minor
---

Added utilities for generating and verifying backend wallet signatures
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ SDK for the Treasure ecosystem
- [Treasure Connect (React)](./examples/connect-react) - [Live Demo](https://tdk-react-demo.spellcaster.lol)
- [Treasure Connect (Electron)](./examples/connect-electron)
- [Magicswap](./examples/magicswap) - [Live Demo](https://tdk-magicswap-demo.spellcaster.lol)
- [Backend-to-Backend](./examples/backend-to-backend)

## Development

Expand Down
131 changes: 89 additions & 42 deletions apps/api/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,123 @@
import * as Sentry from "@sentry/node";
import {
type AddressString,
type UserContext,
verifyAccountSignature,
verifyBackendWalletSignature,
} from "@treasure-dev/tdk-core";
import { type Address, isAddress, isHex } from "thirdweb";

import { type Hex, isHex } from "thirdweb";
import type { TdkApiContext } from "../types";
import type { App } from "../utils/app";
import { throwUnauthorizedError } from "../utils/error";
import { createUnauthorizedError } from "../utils/error";

declare module "fastify" {
interface FastifyRequest {
userId: string | undefined;
userAddress: AddressString | undefined;
backendUserAddress: AddressString | undefined;
backendWallet: AddressString | undefined;
authError: string | undefined;
isAuthenticated: boolean;
authenticatedUserId: string | undefined;
authenticatedUserAddress: Address | undefined;
authenticationError: string | undefined;
isBackendRequest: boolean;
verifiedBackendWallet: Address | undefined;
verifiedBackendUserAddress: Address | undefined;
}
}

export const withAuth = (app: App, { auth }: TdkApiContext) => {
app.decorateRequest("userId", undefined);
app.decorateRequest("userAddress", undefined);
app.decorateRequest("backendUserAddress", undefined);
app.decorateRequest("backendWallet", undefined);
app.decorateRequest("authError", undefined);
export const withAuth = (app: App, { auth, client }: TdkApiContext) => {
app.decorateRequest("isAuthenticated", false);
app.decorateRequest("authenticatedUserId", undefined);
app.decorateRequest("authenticatedUserAddress", undefined);
app.decorateRequest("authenticationError", undefined);
app.decorateRequest("isBackendRequest", false);
app.decorateRequest("verifiedBackendWallet", undefined);
app.decorateRequest("verifiedBackendUserAddress", undefined);
app.addHook("onRequest", async (req) => {
// Check for explicit setting of user address and recover backend wallet address from signature
if (req.headers["x-account-address"]) {
const accountAddress = req.headers["x-account-address"].toString();
const signature = req.headers["x-account-signature"]?.toString();
if (!isHex(accountAddress) || !isHex(signature)) {
throwUnauthorizedError("Invalid account address or signature");
if (!isAddress(accountAddress)) {
throw createUnauthorizedError("Invalid account address");
}

const accountSignature = req.headers["x-account-signature"]?.toString();
if (!isHex(accountSignature)) {
throw createUnauthorizedError("Invalid account signature");
}

const expirationTime = Number(
req.headers["x-account-signature-expiration"],
);

const backendWallet = await verifyAccountSignature({
accountAddress,
signature: signature as Hex,
signature: accountSignature,
expirationTime,
});
if (!backendWallet) {
throwUnauthorizedError("Invalid backend wallet address");
}

req.backendUserAddress = accountAddress as AddressString;
req.backendWallet = backendWallet as AddressString;
Sentry.setUser({ username: req.backendUserAddress });
req.isBackendRequest = true;
req.verifiedBackendWallet = backendWallet as Address;
req.verifiedBackendUserAddress = accountAddress;
Sentry.setUser({ username: req.verifiedBackendUserAddress });
return;
}

// All other auth methods require an Authorization header
if (!req.headers.authorization) {
// Check for explicit setting of backend wallet and recover address from signature
if (req.headers["x-backend-wallet-signature"]) {
const backendWallet = req.headers["x-backend-wallet"]?.toString();
if (!backendWallet || !isAddress(backendWallet)) {
throw createUnauthorizedError("Invalid backend wallet address");
}

const backendWalletSignature =
req.headers["x-backend-wallet-signature"]?.toString();
if (!isHex(backendWalletSignature)) {
throw createUnauthorizedError("Invalid backend wallet signature");
}

const expirationTime = Number(
req.headers["x-backend-wallet-signature-expiration"],
);
if (Number.isNaN(expirationTime)) {
throw createUnauthorizedError(
"Invalid backend wallet signature expiration",
);
}

const verifiedBackendWallet = await verifyBackendWalletSignature({
client,
chainId: req.chain.id,
backendWallet,
signature: backendWalletSignature,
expirationTime,
});

req.isBackendRequest = true;
req.verifiedBackendWallet =
verifiedBackendWallet.toLowerCase() as Address;
return;
}

// Check for user address via JWT header
try {
const decoded = await auth.verifyJWT<UserContext>(
req.headers.authorization.replace("Bearer ", ""),
);
req.userId = decoded.ctx.id;
req.userAddress = (decoded.ctx.smartAccounts.find(
({ chainId }) => chainId === req.chain.id,
)?.address ??
decoded.ctx.address ??
decoded.sub) as AddressString;
Sentry.setUser({
id: req.userId,
username: req.userAddress,
});
} catch (err) {
req.authError = err instanceof Error ? err.message : "Unknown error";
if (req.headers.authorization) {
try {
const decoded = await auth.verifyJWT<UserContext>(
req.headers.authorization.replace("Bearer ", ""),
);
const smartAccount = decoded.ctx.smartAccounts.find(
(account) => account.chainId === req.chain.id,
);
req.isAuthenticated = true;
req.authenticatedUserId = decoded.ctx.id;
req.authenticatedUserAddress = (smartAccount?.address ??
decoded.ctx.address ??
decoded.sub) as Address;
Sentry.setUser({
id: req.authenticatedUserId,
username: req.authenticatedUserAddress,
});
} catch (err) {
req.authenticationError =
err instanceof Error ? err.message : "Unknown error";
}
}
});
};
66 changes: 44 additions & 22 deletions apps/api/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ import {
USER_SMART_ACCOUNT_SELECT_FIELDS,
USER_SOCIAL_ACCOUNT_SELECT_FIELDS,
} from "../utils/db";
import { throwUnauthorizedError, throwUserNotFoundError } from "../utils/error";
import {
createUnauthorizedError,
createUserNotFoundError,
} from "../utils/error";
import { log } from "../utils/log";
import {
migrateLegacyUser,
Expand Down Expand Up @@ -99,22 +102,34 @@ export const authRoutes =
body: {
payload: unverifiedPayload,
signature,
adminAccount: adminAccountData,
authTokenDurationSec = 86_400, // 1 day
},
} = req;
const verifiedPayload = await thirdwebAuth.verifyPayload({
payload: unverifiedPayload,
signature,
});
if (!verifiedPayload.valid) {
const [verifyPayloadResult, verifyAdminAccountPayloadResult] =
await Promise.all([
thirdwebAuth.verifyPayload({
payload: unverifiedPayload,
signature,
}),
adminAccountData
? thirdwebAuth.verifyPayload({
payload: adminAccountData.payload,
signature: adminAccountData.signature,
})
: undefined,
]);
if (!verifyPayloadResult.valid) {
return reply
.code(400)
.send({ error: `Login failed: ${verifiedPayload.error}` });
.send({ error: `Login failed: ${verifyPayloadResult.error}` });
}

const { payload } = verifiedPayload;
const chainId = Number(payload.chain_id ?? DEFAULT_TDK_CHAIN_ID);
const address = payload.address.toLowerCase();
const verifiedPayload = verifyPayloadResult.payload;
const chainId = Number(
verifiedPayload.chain_id ?? DEFAULT_TDK_CHAIN_ID,
);
const address = verifiedPayload.address.toLowerCase();
const chain = defineChain(chainId);

const foundUserSmartAccount = await db.userSmartAccount.findUnique({
Expand Down Expand Up @@ -149,19 +164,27 @@ export const authRoutes =
// On ZKsync chains, the smart account address is the same as the admin wallet address
if (await isZkSyncChain(chain)) {
adminWalletAddress = address.toLowerCase();
} else if (verifyAdminAccountPayloadResult?.valid) {
adminWalletAddress =
verifyAdminAccountPayloadResult.payload.address;
} else {
// Fetch admin wallets associated with this smart account address
const { result } = await engine.account.getAllAdmins(
chainId.toString(),
address,
);
adminWalletAddress = result[0]?.toLowerCase();
try {
// Fetch admin wallets associated with this smart account address
const { result } = await engine.account.getAllAdmins(
chainId.toString(),
address,
);
adminWalletAddress = result[0]?.toLowerCase();
} catch (err) {
log.error(`Error fetching admin wallets: ${err}`);
}
}

// Smart accounts should never be orphaned, but checking anyway
if (!adminWalletAddress) {
throwUnauthorizedError("No admin wallet found for smart account");
return;
throw createUnauthorizedError(
"No admin wallet found for smart account",
);
}

// Fetch Thirdweb user details by ecosystem wallet address
Expand Down Expand Up @@ -258,13 +281,12 @@ export const authRoutes =
]);

if (!user) {
throwUserNotFoundError();
return;
throw createUserNotFoundError();
}

const [authTokenResult, userSessionsResult] = await Promise.allSettled([
auth.generateJWT<UserContext>(address, {
issuer: payload.domain,
issuer: verifiedPayload.domain,
issuedAt: new Date(),
expiresAt: new Date(
new Date().getTime() + authTokenDurationSec * 1000,
Expand Down Expand Up @@ -370,7 +392,7 @@ export const authRoutes =
return reply.send(user);
}

throwUnauthorizedError("Invalid request");
throw createUnauthorizedError("Invalid request");
},
);
};
6 changes: 3 additions & 3 deletions apps/api/src/routes/harvesters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const harvestersRoutes =
const {
chain,
params: { id },
userAddress,
authenticatedUserAddress,
} = req;

const harvesterAddress = id as AddressString;
Expand All @@ -62,11 +62,11 @@ export const harvestersRoutes =
});
}

const harvesterUserInfo = userAddress
const harvesterUserInfo = authenticatedUserAddress
? await getHarvesterUserInfo({
chainId: chain.id,
harvesterInfo,
userAddress,
userAddress: authenticatedUserAddress,
inventoryApiUrl: env.TROVE_API_URL,
inventoryApiKey: env.TROVE_API_KEY,
wagmiConfig,
Expand Down
Loading

0 comments on commit e1154ae

Please sign in to comment.