Skip to content

Commit 1271515

Browse files
tsmblprsvic
andauthored
Feat/spec 2.4 sign message (#28)
* wip implementation * actualize sign-message utils * publish 0.13.0-beta.0 * upd sign message utils * publish 0.13.0-beta.1 * simplify / remove validations * add word break * remove console logs * add reference to orig for sing message utils --------- Co-authored-by: Victoria Prusakova <[email protected]>
1 parent cbb8edf commit 1271515

File tree

10 files changed

+244
-9
lines changed

10 files changed

+244
-9
lines changed

bun.lockb

1.09 KB
Binary file not shown.

packages/blinks-core/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@dialectlabs/blinks-core",
3-
"version": "0.12.1",
3+
"version": "0.13.0-beta.1",
44
"license": "Apache-2.0",
55
"private": false,
66
"sideEffects": true,
@@ -27,7 +27,7 @@
2727
"dist"
2828
],
2929
"devDependencies": {
30-
"@solana/actions-spec": "~2.3.0",
30+
"@solana/actions-spec": "~2.4.0",
3131
"@types/react": "^18.3.3",
3232
"@types/react-dom": "^18.3.0",
3333
"@typescript-eslint/eslint-plugin": "^7.16.1",

packages/blinks-core/src/BlinkContainer.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
import { checkSecurity, isInterstitial, type SecurityLevel } from './utils';
2525
import {
2626
isPostRequestError,
27+
isSignMessageError,
2728
isSignTransactionError,
2829
} from './utils/type-guards.ts';
2930
import { isURL } from './utils/validators.ts';
@@ -486,6 +487,7 @@ export const BlinkContainer = ({
486487
const nextAction = await action.chain(response.links.next, {
487488
signature: signature,
488489
account: account,
490+
state: response.type === 'message' ? response.state : undefined,
489491
});
490492

491493
// if this is running in partial action mode, then we end the chain, if passed fn returns a null value for the next action
@@ -518,6 +520,20 @@ export const BlinkContainer = ({
518520
return;
519521
}
520522

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

packages/blinks-core/src/api/ActionConfig.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { SignMessageData } from '@solana/actions-spec';
12
import { Connection } from '@solana/web3.js';
23
import {
34
type Action,
@@ -14,7 +15,7 @@ export interface ActionContext {
1415

1516
export interface IncomingActionConfig {
1617
rpcUrl: string;
17-
adapter: Pick<ActionAdapter, 'connect' | 'signTransaction'> &
18+
adapter: Pick<ActionAdapter, 'connect' | 'signTransaction' | 'signMessage'> &
1819
Partial<Pick<ActionAdapter, 'metadata'>>;
1920
}
2021

@@ -43,6 +44,10 @@ export interface ActionAdapter {
4344
signature: string,
4445
context: ActionContext,
4546
) => Promise<void>;
47+
signMessage: (
48+
data: string | SignMessageData,
49+
context: ActionContext,
50+
) => Promise<{ signature: string } | { error: string }>;
4651
}
4752

4853
export class ActionConfig implements ActionAdapter {
@@ -116,6 +121,13 @@ export class ActionConfig implements ActionAdapter {
116121
});
117122
}
118123

124+
async signMessage(
125+
data: string | SignMessageData,
126+
context: ActionContext,
127+
): Promise<{ signature: string } | { error: string }> {
128+
return this.adapter.signMessage(data, context);
129+
}
130+
119131
async connect(context: ActionContext) {
120132
try {
121133
return await this.adapter.connect(context);

packages/blinks-core/src/utils/type-guards.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export const isSignTransactionError = (
44
data: { signature: string } | { error: string },
55
): data is { error: string } => !!(data as any).error;
66

7+
export const isSignMessageError = isSignTransactionError;
8+
79
export const isPostRequestError = (
810
data: ActionPostResponse | { error: string },
911
): data is { error: string } => !!(data as any).error;

packages/blinks/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@dialectlabs/blinks",
3-
"version": "0.12.1",
3+
"version": "0.13.0-beta.2",
44
"license": "Apache-2.0",
55
"private": false,
66
"sideEffects": true,
@@ -38,7 +38,7 @@
3838
"dist"
3939
],
4040
"devDependencies": {
41-
"@solana/actions-spec": "~2.3.0",
41+
"@solana/actions-spec": "~2.4.0",
4242
"@types/react": "^18.3.3",
4343
"@types/react-dom": "^18.3.0",
4444
"@typescript-eslint/eslint-plugin": "^7.16.1",
@@ -58,6 +58,7 @@
5858
"typescript": "^5.5.3"
5959
},
6060
"peerDependencies": {
61+
"bs58": "^5.0.0",
6162
"@solana/wallet-adapter-react": "^0.15.0",
6263
"@solana/wallet-adapter-react-ui": "^0.9.0",
6364
"@solana/web3.js": "^1.95.1",
@@ -66,6 +67,6 @@
6667
},
6768
"dependencies": {
6869
"clsx": "^2.1.1",
69-
"@dialectlabs/blinks-core": "0.12.1"
70+
"@dialectlabs/blinks-core": "0.13.0-beta.1"
7071
}
7172
}

packages/blinks/src/hooks/solana/useActionSolanaWalletAdapter.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
'use client';
2-
import { ActionConfig } from '@dialectlabs/blinks-core';
2+
import {
3+
ActionConfig,
4+
createSignMessageText,
5+
type SignMessageVerificationOptions,
6+
verifySignMessageData,
7+
} from '@dialectlabs/blinks-core';
8+
9+
import type { SignMessageData } from '@solana/actions-spec';
310
import { useWallet } from '@solana/wallet-adapter-react';
411
import { useWalletModal } from '@solana/wallet-adapter-react-ui';
512
import { Connection, VersionedTransaction } from '@solana/web3.js';
13+
import bs58 from 'bs58';
614
import { useMemo } from 'react';
7-
815
/**
916
* Hook to create an action adapter using solana's wallet adapter.
1017
*
@@ -26,6 +33,23 @@ export function useActionSolanaWalletAdapter(
2633
}, [rpcUrlOrConnection]);
2734

2835
const adapter = useMemo(() => {
36+
function verifySignDataValidity(
37+
data: string | SignMessageData,
38+
opts: SignMessageVerificationOptions,
39+
) {
40+
if (typeof data === 'string') {
41+
// skip validation for string
42+
return true;
43+
}
44+
const errors = verifySignMessageData(data, opts);
45+
if (errors.length > 0) {
46+
console.warn(
47+
`[@dialectlabs/blinks] Sign message data verification error: ${errors.join(', ')}`,
48+
);
49+
}
50+
return errors.length === 0;
51+
}
52+
2953
return new ActionConfig(finalConnection, {
3054
connect: async () => {
3155
try {
@@ -48,6 +72,35 @@ export function useActionSolanaWalletAdapter(
4872
return { error: 'Signing failed.' };
4973
}
5074
},
75+
signMessage: async (
76+
data: string | SignMessageData,
77+
): Promise<
78+
| { signature: string }
79+
| {
80+
error: string;
81+
}
82+
> => {
83+
if (!wallet.signMessage || !wallet.publicKey) {
84+
return { error: 'Signing failed.' };
85+
}
86+
try {
87+
// Optional data verification before signing
88+
const isSignDataValid = verifySignDataValidity(data, {
89+
expectedAddress: wallet.publicKey.toString(),
90+
});
91+
if (!isSignDataValid) {
92+
return { error: 'Signing failed.' };
93+
}
94+
const text =
95+
typeof data === 'string' ? data : createSignMessageText(data);
96+
const encoded = new TextEncoder().encode(text);
97+
const signed = await wallet.signMessage(encoded);
98+
const encodedSignature = bs58.encode(signed);
99+
return { signature: encodedSignature };
100+
} catch (e) {
101+
return { error: 'Signing failed.' };
102+
}
103+
},
51104
});
52105
}, [finalConnection, wallet, walletModal]);
53106

packages/blinks/src/ui/layouts/BaseBlinkLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ export const BaseBlinkLayout = ({
266266
<span className="text-text text-text-primary mb-0.5 font-semibold">
267267
{title}
268268
</span>
269-
<span className="text-subtext text-text-secondary mb-4 whitespace-pre-wrap">
269+
<span className="text-subtext text-text-secondary mb-4 whitespace-pre-wrap break-words">
270270
{description}
271271
</span>
272272
{!supportability.isSupported ? (

0 commit comments

Comments
 (0)