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

feat: add portal to aa-signers #303

Merged
merged 7 commits into from
Dec 12, 2023
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
2 changes: 2 additions & 0 deletions packages/signers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@fireblocks/fireblocks-web3-provider": "^1.2.6",
"@particle-network/auth": "^1.2.2",
"@particle-network/provider": "^1.2.1",
"@portal-hq/web": "^0.0.8",
"@web3auth/base": "^7.1.0",
"@web3auth/modal": "^7.1.1",
"jsdom": "^22.1.0",
Expand Down Expand Up @@ -76,6 +77,7 @@
"@fireblocks/fireblocks-web3-provider": "^1.2.6",
"@particle-network/auth": "^1.2.2",
"@particle-network/provider": "^1.2.1",
"@portal-hq/web": "^0.0.8",
"@web3auth/base": "^7.1.0",
"@web3auth/modal": "^7.1.1",
"magic-sdk": "^21.3.0"
Expand Down
12 changes: 8 additions & 4 deletions packages/signers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@ export {
type FireblocksAuthenticationParams,
} from "./fireblocks/index.js";
export { MagicSigner, type MagicAuthParams } from "./magic/index.js";
export {
ParticleSigner,
type ParticleAuthenticationParams,
} from "./particle/index.js";
export {
PortalSigner,
type PortalAuthenticationParams,
} from "./portal/index.js";
export {
TurnkeySigner,
TurnkeySubOrganization,
type TurnkeyAuthParams,
} from "./turnkey/index.js";
export {
ParticleSigner,
type ParticleAuthenticationParams,
} from "./particle/index.js";
export {
Web3AuthSigner,
type Web3AuthAuthenticationParams,
Expand Down
130 changes: 130 additions & 0 deletions packages/signers/src/portal/__tests__/signer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import Portal from "@portal-hq/web";
import { sepolia } from "viem/chains";
import { PortalSigner } from "../signer.js";

// taken from Portal SDK since not exported
interface RequestArguments {
method: string;
params?: unknown[];
}

describe("Portal Signer Tests", () => {
it("should correctly get address", async () => {
const signer = await givenSigner();

const address = await signer.getAddress();
expect(address).toMatchInlineSnapshot(
'"0x1234567890123456789012345678901234567890"'
);
});

it("should correctly fail to get address if unauthenticated", async () => {
const signer = await givenSigner(false);

const address = signer.getAddress();
await expect(address).rejects.toThrowErrorMatchingInlineSnapshot(
'"Not authenticated"'
);
});

it("should correctly get auth details", async () => {
const signer = await givenSigner();

const details = await signer.getAuthDetails();
expect(details).toMatchInlineSnapshot(`
{
"address": "0x1234567890123456789012345678901234567890",
"backupStatus": null,
"custodian": {
"id": "1",
"name": "test",
},
"id": "0",
"signingStatus": null,
}
`);
});

it("should correctly fail to get auth details if unauthenticated", async () => {
const signer = await givenSigner(false);

const details = signer.getAuthDetails();
await expect(details).rejects.toThrowErrorMatchingInlineSnapshot(
'"Not authenticated"'
);
});

it("should correctly sign message if authenticated", async () => {
const signer = await givenSigner();

const signMessage = await signer.signMessage("test");
expect(signMessage).toMatchInlineSnapshot('"0xtest"');
});

it("should correctly fail to sign message if unauthenticated", async () => {
const signer = await givenSigner(false);

const signMessage = signer.signMessage("test");
await expect(signMessage).rejects.toThrowErrorMatchingInlineSnapshot(
'"Not authenticated"'
);
});

it("should correctly sign typed data if authenticated", async () => {
const signer = await givenSigner();

const typedData = {
types: {
Request: [{ name: "hello", type: "string" }],
},
primaryType: "Request",
message: {
hello: "world",
},
};
const signTypedData = await signer.signTypedData(typedData);
expect(signTypedData).toMatchInlineSnapshot('"0xtest"');
});
});

const givenSigner = async (auth = true) => {
const inner = new Portal({
autoApprove: true,
gatewayConfig: `${sepolia.rpcUrls.alchemy.http}/${process.env.ALCHEMY_API_KEY}`,
chainId: sepolia.id,
});

inner.getClient = vi.fn().mockResolvedValue({
id: "0",
address: "0x1234567890123456789012345678901234567890",
backupStatus: null,
custodian: {
id: "1",
name: "test",
},
signingStatus: null,
});

inner.provider.request = vi.fn(async <R>(args: RequestArguments) => {
switch (args.method) {
case "eth_accounts":
return Promise.resolve([
"0x1234567890123456789012345678901234567890",
]) as R;
case "personal_sign":
return Promise.resolve("0xtest") as R;
case "eth_signTypedData_v4":
return Promise.resolve("0xtest") as R;
default:
return Promise.reject(new Error("Method not found"));
}
});

const signer = new PortalSigner({ inner });

if (auth) {
await signer.authenticate();
}

return signer;
};
2 changes: 2 additions & 0 deletions packages/signers/src/portal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { PortalSigner } from "./signer.js";
export type * from "./types.js";
75 changes: 75 additions & 0 deletions packages/signers/src/portal/signer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {
WalletClientSigner,
type SignTypedDataParams,
type SmartAccountAuthenticator,
} from "@alchemy/aa-core";
import Portal, { type PortalOptions } from "@portal-hq/web";
import { createWalletClient, custom, type Hash } from "viem";
import type { PortalAuthenticationParams, PortalUserInfo } from "./types.js";

/**
* This class requires the `@portal-hq/web` dependency.
* `@alchemy/aa-signers` lists it as an optional dependency.
*
* @see: https://docs.portalhq.io/sdk/web-beta
*/
export class PortalSigner
implements
SmartAccountAuthenticator<
PortalAuthenticationParams,
PortalUserInfo,
Portal
>
{
inner: Portal;
private signer: WalletClientSigner | undefined;

constructor(params: PortalOptions | { inner: Portal }) {
if ("inner" in params) {
this.inner = params.inner;
return;
}

this.inner = new Portal(params);
}

readonly signerType = "portal";

getAddress = async () => {
if (!this.signer) throw new Error("Not authenticated");

const address = await this.signer.getAddress();
if (address == null) throw new Error("No address found");

return address as Hash;
};

signMessage = async (msg: Uint8Array | string) => {
if (!this.signer) throw new Error("Not authenticated");

return this.signer.signMessage(msg);
};

signTypedData = (params: SignTypedDataParams) => {
if (!this.signer) throw new Error("Not authenticated");

return this.signer.signTypedData(params);
};

authenticate = async () => {
this.signer = new WalletClientSigner(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not just create this in the constructor and have authenticate return this.getAuthDetails?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just for convention, because we want the dev to call authenticate before calling getAddress, sendMessage, etc. like is necessary with the other signers

createWalletClient({
transport: custom(this.inner.provider),
}),
this.signerType
);

return this.inner.getClient() as Promise<PortalUserInfo>;
};

getAuthDetails = async () => {
if (!this.signer) throw new Error("Not authenticated");

return this.inner.getClient() as Promise<PortalUserInfo>;
};
}
15 changes: 15 additions & 0 deletions packages/signers/src/portal/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Address } from "viem";

export interface PortalAuthenticationParams {}

// taken from Portal SDK since not exported

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can export this for y'all!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll queue up an update.

export type PortalUserInfo = {
id: string;
address: Address;
backupStatus?: string | null;
custodian: {
id: string;
name: string;
};
signingStatus?: string | null;
};
14 changes: 14 additions & 0 deletions site/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,20 @@ export default defineConfig({
{ text: "getAuthDetails", link: "/getAuthDetails" },
],
},
{
text: "Portal Signer",
collapsed: true,
base: "/packages/aa-signers/portal",
items: [
{ text: "Introduction", link: "/introduction" },
{ text: "constructor", link: "/constructor" },
{ text: "authenticate", link: "/authenticate" },
{ text: "getAddress", link: "/getAddress" },
{ text: "signMessage", link: "/signMessage" },
{ text: "signTypedData", link: "/signTypedData" },
{ text: "getAuthDetails", link: "/getAuthDetails" },
],
},
{ text: "Contributing", link: "/contributing" },
],
},
Expand Down
54 changes: 54 additions & 0 deletions site/packages/aa-signers/portal/authenticate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
outline: deep
head:
- - meta
- property: og:title
content: PortalSigner • authenticate
- - meta
- name: description
content: Overview of the authenticate method on PortalSigner
- - meta
- property: og:description
content: Overview of the authenticate method on PortalSigner
---

# authenticate

`authenticate` is a method on the `PortalSigner` which leverages the `Portal` SDK to authenticate a user.

You must call this method before accessing the other methods available on the `PortalSigner`, such as signing messages or typed data or accessing user details.

## Usage

::: code-group

```ts [example.ts]
// [!code focus:99]
import { PortalSigner } from "@alchemy/aa-signers";

const portalSigner = new PortalSigner({
autoApprove: true,
gatewayConfig: `${sepolia.rpcUrls.alchemy.http}/${process.env.ALCHEMY_API_KEY}`,
chainId: sepolia.id,
});

await portalSigner.authenticate();
```

:::

## Returns

### `Promise<PortalUserInfo>`

A Promise containing the `PortalUserInfo`, an object with the following fields:

- `id: string` -- ID of the Portal Signer.
- `address: string` -- EOA address of the Portal Signer.
- `backupStatus: string | null` -- [optional] status of wallet backup.
- `custodian: Obect` -- [optional] EOA address of the Portal Signer.
- `id: string` -- ID of the Signer's custodian.
- `name: string` -- Name of the Signer's custodian.
- `signingStatus: string | null` -- [optional] status of signing.

This derives from the return type of a Portal provider's `getClient()` method.
Loading
Loading