|
| 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 | +} |
0 commit comments