Skip to content

Commit 6f88487

Browse files
authored
Merge pull request #33 from dialectlabs/chore/sign-message-clarificaitons
Chore/sign message clarificaitons
2 parents c62dd4b + ac9b005 commit 6f88487

File tree

9 files changed

+9853
-728
lines changed

9 files changed

+9853
-728
lines changed

package-lock.json

Lines changed: 9324 additions & 723 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
"engines": {
1010
"node": ">=16"
1111
},
12+
"workspaces": [
13+
"packages/*"
14+
],
1215
"scripts": {
1316
"prettier": "npx prettier --write '{*,**/*}.{ts,tsx,js,jsx,css,json}'"
1417
},

packages/actions-spec/README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,48 @@ export type SignMessageData = {
748748
When received by the blink client, the user should be shown the plaintext `data`
749749
value and prompted to sign it with their wallet to generate a `signature`.
750750

751+
The `data` can be a plaintext string or a structured `SignMessageData` object.
752+
753+
When using `SignMessageData`, it must be formatted as a standardized, human-readable plaintext suitable for signing.
754+
Both the client and server must generate the message using the same method to ensure proper verification.
755+
The following template must be used by both the Action API and the client to format `SignMessageData`:
756+
757+
```
758+
${domain} wants you to sign a message with your account:
759+
${address}
760+
761+
${statement}
762+
763+
Chain ID: ${chainId}
764+
Nonce: ${nonce}
765+
Issued At: ${issuedAt}
766+
```
767+
768+
If `chainId` is not provided, the `Chain ID` line should be omitted from the message to be signed.
769+
770+
Client should not prefix, suffix or otherwise modify the `SignMessageData` value before signing it.
771+
Client should perform validation on the `SignMessageData` before signing to ensure that it meets expected criteria and to prevent potential security issues.
772+
773+
The following function illustrates how to create a human-readable message text from `SignMessageData`:
774+
775+
```ts
776+
export function createSignMessageText(input: SignMessageData): string {
777+
let message = `${input.domain} wants you to sign a message with your account:\n`;
778+
message += `${input.address}`;
779+
message += `\n\n${input.statement}`;
780+
const fields: string[] = [];
781+
782+
if (input.chainId) {
783+
fields.push(`Chain ID: ${input.chainId}`);
784+
}
785+
fields.push(`Nonce: ${input.nonce}`);
786+
fields.push(`Issued At: ${input.issuedAt}`);
787+
message += `\n\n${fields.join("\n")}`;
788+
789+
return message;
790+
}
791+
```
792+
751793
After signing, the blink client will continue the chain-of-actions by making a
752794
POST request to the provided `PostNextActionLink` endpoint with a payload
753795
similar to the normal `ActionPostRequest` fields (see

packages/actions-spec/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
},
4242
{
4343
"name": "Alexey Tsymbal",
44-
"url": "https://github.com/Baulore"
44+
"url": "https://github.com/tsmbl"
4545
},
4646
{
4747
"name": "Filipp Sher",

packages/solana-actions/src/createPostResponse.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
createActionIdentifierInstruction,
1414
getActionIdentityFromEnv,
1515
} from "./actionIdentity.js";
16-
import { ActionPostResponse } from "@solana/actions-spec";
16+
import { ActionPostResponse, TransactionResponse } from "@solana/actions-spec";
1717

1818
/**
1919
* Thrown when the Action POST response cannot be created.
@@ -29,7 +29,7 @@ export interface CreateActionPostResponseArgs<
2929
TransactionType = Transaction | VersionedTransaction,
3030
> {
3131
/** POST response fields per the Solana Actions spec. */
32-
fields: Omit<ActionPostResponse, "transaction"> & {
32+
fields: Omit<TransactionResponse, "transaction"> & {
3333
/** Solana transaction to be base64 encoded. */
3434
transaction: TransactionType;
3535
};

packages/solana-actions/src/fetchTransaction.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { ActionPostRequest, ActionPostResponse } from "@solana/actions-spec";
1+
import {
2+
ActionPostRequest,
3+
ActionPostResponse,
4+
TransactionResponse,
5+
} from "@solana/actions-spec";
26
import { Commitment, Connection, PublicKey } from "@solana/web3.js";
37
import { Transaction } from "@solana/web3.js";
48
import fetch from "cross-fetch";
@@ -49,7 +53,7 @@ export async function fetchTransaction(
4953
body: JSON.stringify(fields),
5054
});
5155

52-
const json = (await response.json()) as ActionPostResponse;
56+
const json = (await response.json()) as TransactionResponse;
5357
if (!json?.transaction) throw new FetchActionError("missing transaction");
5458
if (typeof json.transaction !== "string")
5559
throw new FetchActionError("invalid transaction");

packages/solana-actions/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export * from "./fetchTransaction.js";
1010
export * from "./findReference.js";
1111
export * from "./createPostResponse.js";
1212
export * from "./actionIdentity.js";
13+
export * from "./signMessageData.js";
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import type { SignMessageData } from "@solana/actions-spec";
2+
3+
export interface SignMessageVerificationOptions {
4+
expectedAddress?: string;
5+
expectedDomains?: string[];
6+
expectedChainIds?: string[];
7+
issuedAtThreshold?: number;
8+
}
9+
10+
export enum SignMessageVerificationErrorType {
11+
ADDRESS_MISMATCH = "ADDRESS_MISMATCH",
12+
DOMAIN_MISMATCH = "DOMAIN_MISMATCH",
13+
CHAIN_ID_MISMATCH = "CHAIN_ID_MISMATCH",
14+
ISSUED_TOO_FAR_IN_THE_PAST = "ISSUED_TOO_FAR_IN_THE_PAST",
15+
ISSUED_TOO_FAR_IN_THE_FUTURE = "ISSUED_TOO_FAR_IN_THE_FUTURE",
16+
INVALID_DATA = "INVALID_DATA",
17+
}
18+
19+
const DOMAIN =
20+
"(?<domain>[^\\n]+?) wants you to sign a message with your account:\\n";
21+
const ADDRESS = "(?<address>[^\\n]+)(?:\\n|$)";
22+
const STATEMENT = "(?:\\n(?<statement>[\\S\\s]*?)(?:\\n|$))";
23+
const CHAIN_ID = "(?:\\nChain ID: (?<chainId>[^\\n]+))?";
24+
const NONCE = "\\nNonce: (?<nonce>[^\\n]+)";
25+
const ISSUED_AT = "\\nIssued At: (?<issuedAt>[^\\n]+)";
26+
const FIELDS = `${CHAIN_ID}${NONCE}${ISSUED_AT}`;
27+
const MESSAGE = new RegExp(`^${DOMAIN}${ADDRESS}${STATEMENT}${FIELDS}\\n*$`);
28+
29+
/**
30+
* Create a human-readable message text for the user to sign.
31+
*
32+
* @param input The data to be signed.
33+
* @returns The message text.
34+
*/
35+
export function createSignMessageText(input: SignMessageData): string {
36+
let message = `${input.domain} wants you to sign a message with your account:\n`;
37+
message += `${input.address}`;
38+
message += `\n\n${input.statement}`;
39+
const fields: string[] = [];
40+
41+
if (input.chainId) {
42+
fields.push(`Chain ID: ${input.chainId}`);
43+
}
44+
fields.push(`Nonce: ${input.nonce}`);
45+
fields.push(`Issued At: ${input.issuedAt}`);
46+
message += `\n\n${fields.join("\n")}`;
47+
48+
return message;
49+
}
50+
51+
/**
52+
* Parse the sign message text to extract the data to be signed.
53+
* @param text The message text to be parsed.
54+
*/
55+
export function parseSignMessageText(text: string): SignMessageData | null {
56+
const match = MESSAGE.exec(text);
57+
if (!match) return null;
58+
const groups = match.groups;
59+
if (!groups) return null;
60+
61+
return {
62+
domain: groups.domain,
63+
address: groups.address,
64+
statement: groups.statement,
65+
nonce: groups.nonce,
66+
chainId: groups.chainId,
67+
issuedAt: groups.issuedAt,
68+
};
69+
}
70+
71+
/**
72+
* Verify the sign message data before signing.
73+
* @param data The data to be signed.
74+
* @param opts Options for verification, including the expected address, chainId, issuedAt, and domains.
75+
*
76+
* @returns An array of errors if the verification fails.
77+
*/
78+
export function verifySignMessageData(
79+
data: SignMessageData,
80+
opts: SignMessageVerificationOptions,
81+
) {
82+
if (
83+
!data.address ||
84+
!data.domain ||
85+
!data.issuedAt ||
86+
!data.nonce ||
87+
!data.statement
88+
) {
89+
return [SignMessageVerificationErrorType.INVALID_DATA];
90+
}
91+
92+
try {
93+
const {
94+
expectedAddress,
95+
expectedChainIds,
96+
issuedAtThreshold,
97+
expectedDomains,
98+
} = opts;
99+
const errors: SignMessageVerificationErrorType[] = [];
100+
const now = Date.now();
101+
102+
// verify if parsed address is same as the expected address
103+
if (expectedAddress && data.address !== expectedAddress) {
104+
errors.push(SignMessageVerificationErrorType.ADDRESS_MISMATCH);
105+
}
106+
107+
if (expectedDomains) {
108+
const expectedDomainsNormalized = expectedDomains.map(normalizeDomain);
109+
const normalizedDomain = normalizeDomain(data.domain);
110+
111+
if (!expectedDomainsNormalized.includes(normalizedDomain)) {
112+
errors.push(SignMessageVerificationErrorType.DOMAIN_MISMATCH);
113+
}
114+
}
115+
116+
if (
117+
expectedChainIds &&
118+
data.chainId &&
119+
!expectedChainIds.includes(data.chainId)
120+
) {
121+
errors.push(SignMessageVerificationErrorType.CHAIN_ID_MISMATCH);
122+
}
123+
124+
if (issuedAtThreshold !== undefined) {
125+
const iat = Date.parse(data.issuedAt);
126+
if (Math.abs(iat - now) > issuedAtThreshold) {
127+
if (iat < now) {
128+
errors.push(
129+
SignMessageVerificationErrorType.ISSUED_TOO_FAR_IN_THE_PAST,
130+
);
131+
} else {
132+
errors.push(
133+
SignMessageVerificationErrorType.ISSUED_TOO_FAR_IN_THE_FUTURE,
134+
);
135+
}
136+
}
137+
}
138+
139+
return errors;
140+
} catch (e) {
141+
return [SignMessageVerificationErrorType.INVALID_DATA];
142+
}
143+
}
144+
145+
function normalizeDomain(domain: string): string {
146+
return domain.replace(/^www\./, "");
147+
}

0 commit comments

Comments
 (0)