Skip to content

Commit

Permalink
feat: add portal to aa-signers (#303)
Browse files Browse the repository at this point in the history
  • Loading branch information
avasisht23 committed Dec 12, 2023
1 parent e049c2c commit eb8a0c3
Show file tree
Hide file tree
Showing 18 changed files with 691 additions and 26 deletions.
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(
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
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.

0 comments on commit eb8a0c3

Please sign in to comment.