|
| 1 | +/* eslint-disable no-console */ |
| 2 | +import {ABI, APIClient, FetchProvider} from '@wharfkit/antelope' |
| 3 | +import {Chains} from '@wharfkit/common' |
| 4 | +import {PlaceholderName, PlaceholderPermission, SigningRequest} from '@wharfkit/signing-request' |
| 5 | +import fetch from 'node-fetch' |
| 6 | +import {displayQRCode} from '../../utils' |
| 7 | + |
| 8 | +export interface ActionRequestOptions { |
| 9 | + chain?: string |
| 10 | + auth?: string |
| 11 | +} |
| 12 | + |
| 13 | +/** |
| 14 | + * Parse authorization string (e.g., "account@permission" or "account") |
| 15 | + * Returns undefined for actor/permission to use placeholders |
| 16 | + */ |
| 17 | +export function parseAuthorization(authString?: string): { |
| 18 | + actor: string | null |
| 19 | + permission: string |
| 20 | +} { |
| 21 | + if (!authString) { |
| 22 | + return {actor: null, permission: 'active'} |
| 23 | + } |
| 24 | + |
| 25 | + if (authString.includes('@')) { |
| 26 | + const [actor, permission] = authString.split('@') |
| 27 | + return {actor, permission: permission || 'active'} |
| 28 | + } |
| 29 | + |
| 30 | + return {actor: authString, permission: 'active'} |
| 31 | +} |
| 32 | + |
| 33 | +/** |
| 34 | + * Parse action data from string (JSON or key=value pairs) |
| 35 | + */ |
| 36 | +export function parseActionData(dataString: string): Record<string, unknown> { |
| 37 | + // Try JSON first |
| 38 | + try { |
| 39 | + return JSON.parse(dataString) |
| 40 | + } catch { |
| 41 | + // Try key=value format |
| 42 | + const data: Record<string, unknown> = {} |
| 43 | + const pairs = dataString.split(',').map((p) => p.trim()) |
| 44 | + |
| 45 | + for (const pair of pairs) { |
| 46 | + const [key, ...valueParts] = pair.split('=') |
| 47 | + if (key && valueParts.length > 0) { |
| 48 | + const value = valueParts.join('=').trim() |
| 49 | + // Try to parse as number or boolean |
| 50 | + if (value === 'true') { |
| 51 | + data[key.trim()] = true |
| 52 | + } else if (value === 'false') { |
| 53 | + data[key.trim()] = false |
| 54 | + } else if (!isNaN(Number(value)) && value !== '') { |
| 55 | + data[key.trim()] = Number(value) |
| 56 | + } else { |
| 57 | + data[key.trim()] = value |
| 58 | + } |
| 59 | + } |
| 60 | + } |
| 61 | + |
| 62 | + if (Object.keys(data).length === 0) { |
| 63 | + throw new Error( |
| 64 | + `Invalid action data format. Use JSON (e.g., '{"key": "value"}') or key=value pairs (e.g., 'key1=value1,key2=value2')` |
| 65 | + ) |
| 66 | + } |
| 67 | + |
| 68 | + return data |
| 69 | + } |
| 70 | +} |
| 71 | + |
| 72 | +/** |
| 73 | + * Parse contract::action format and validate |
| 74 | + */ |
| 75 | +export function parseContractAction(contractAction: string): { |
| 76 | + contractAccount: string |
| 77 | + actionName: string |
| 78 | +} { |
| 79 | + if (!contractAction.includes('::')) { |
| 80 | + throw new Error( |
| 81 | + `Invalid format. Use contract::action format (e.g., "eosio.token::transfer")` |
| 82 | + ) |
| 83 | + } |
| 84 | + |
| 85 | + const [contractAccount, actionName] = contractAction.split('::') |
| 86 | + |
| 87 | + if (!contractAccount || !actionName) { |
| 88 | + throw new Error( |
| 89 | + `Invalid format. Use contract::action format (e.g., "eosio.token::transfer")` |
| 90 | + ) |
| 91 | + } |
| 92 | + |
| 93 | + return {contractAccount, actionName} |
| 94 | +} |
| 95 | + |
| 96 | +/** |
| 97 | + * Get the API URL for a chain name or URL |
| 98 | + */ |
| 99 | +export function getApiUrl(chainOrUrl: string): string { |
| 100 | + // Check if it's already a URL |
| 101 | + if (chainOrUrl.startsWith('http://') || chainOrUrl.startsWith('https://')) { |
| 102 | + return chainOrUrl |
| 103 | + } |
| 104 | + |
| 105 | + // Check if it matches a known chain key from @wharfkit/common |
| 106 | + const knownChainKey = Object.keys(Chains).find( |
| 107 | + (key) => key.toLowerCase() === chainOrUrl.toLowerCase() |
| 108 | + ) |
| 109 | + |
| 110 | + if (knownChainKey) { |
| 111 | + return (Chains as Record<string, {url: string}>)[knownChainKey].url |
| 112 | + } |
| 113 | + |
| 114 | + // Default to local |
| 115 | + if (chainOrUrl === 'local') { |
| 116 | + return 'http://127.0.0.1:8888' |
| 117 | + } |
| 118 | + |
| 119 | + throw new Error( |
| 120 | + `Unknown chain: ${chainOrUrl}. Use a full URL (http://...) or a known chain name (EOS, Jungle4, WAX, etc.)` |
| 121 | + ) |
| 122 | +} |
| 123 | + |
| 124 | +/** |
| 125 | + * Create an ESR (EOSIO Signing Request) for any action |
| 126 | + */ |
| 127 | +export async function createActionESR( |
| 128 | + client: APIClient, |
| 129 | + contractAccount: string, |
| 130 | + actionName: string, |
| 131 | + actionData: Record<string, unknown>, |
| 132 | + auth: {actor: string | null; permission: string} |
| 133 | +): Promise<{uri: string; encodedUri: string}> { |
| 134 | + const info = await client.v1.chain.get_info() |
| 135 | + const chainId = String(info.chain_id) |
| 136 | + |
| 137 | + // Fetch the contract ABI for serialization |
| 138 | + const abiResponse = await client.v1.chain.get_abi(contractAccount) |
| 139 | + if (!abiResponse.abi) { |
| 140 | + throw new Error(`Could not fetch ABI for contract: ${contractAccount}`) |
| 141 | + } |
| 142 | + const contractAbi = ABI.from(abiResponse.abi) |
| 143 | + |
| 144 | + // Verify the action exists in the ABI |
| 145 | + const actionDef = contractAbi.actions.find((a) => String(a.name) === actionName) |
| 146 | + if (!actionDef) { |
| 147 | + const availableActions = contractAbi.actions.map((a) => String(a.name)).join(', ') |
| 148 | + throw new Error( |
| 149 | + `Action "${actionName}" not found in contract "${contractAccount}". Available actions: ${availableActions}` |
| 150 | + ) |
| 151 | + } |
| 152 | + |
| 153 | + // Use placeholders if no specific actor is provided |
| 154 | + const actor = auth.actor || PlaceholderName |
| 155 | + const permission = auth.actor ? auth.permission : PlaceholderPermission |
| 156 | + |
| 157 | + // Replace placeholder tokens in data with actual placeholders |
| 158 | + const processedData = processDataPlaceholders(actionData, auth.actor) |
| 159 | + |
| 160 | + // Create the signing request |
| 161 | + const request = await SigningRequest.create( |
| 162 | + { |
| 163 | + action: { |
| 164 | + account: contractAccount, |
| 165 | + name: actionName, |
| 166 | + authorization: [ |
| 167 | + { |
| 168 | + actor, |
| 169 | + permission, |
| 170 | + }, |
| 171 | + ], |
| 172 | + data: processedData, |
| 173 | + }, |
| 174 | + chainId, |
| 175 | + }, |
| 176 | + { |
| 177 | + abiProvider: { |
| 178 | + getAbi: async () => contractAbi, |
| 179 | + }, |
| 180 | + } |
| 181 | + ) |
| 182 | + |
| 183 | + const encodedUri = request.encode() |
| 184 | + // Normalize to esr:// format |
| 185 | + let uri = encodedUri |
| 186 | + if (!uri.startsWith('esr://')) { |
| 187 | + if (uri.startsWith('esr:')) { |
| 188 | + uri = `esr://${uri.slice(4)}` |
| 189 | + } |
| 190 | + } |
| 191 | + |
| 192 | + return {uri, encodedUri} |
| 193 | +} |
| 194 | + |
| 195 | +/** |
| 196 | + * Process data to replace special placeholder tokens |
| 197 | + * Replaces $signer with the PlaceholderName for ESR |
| 198 | + */ |
| 199 | +function processDataPlaceholders( |
| 200 | + data: Record<string, unknown>, |
| 201 | + specificActor: string | null |
| 202 | +): Record<string, unknown> { |
| 203 | + const processed: Record<string, unknown> = {} |
| 204 | + |
| 205 | + for (const [key, value] of Object.entries(data)) { |
| 206 | + if (typeof value === 'string' && value === '$signer') { |
| 207 | + processed[key] = specificActor || PlaceholderName |
| 208 | + } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { |
| 209 | + processed[key] = processDataPlaceholders( |
| 210 | + value as Record<string, unknown>, |
| 211 | + specificActor |
| 212 | + ) |
| 213 | + } else { |
| 214 | + processed[key] = value |
| 215 | + } |
| 216 | + } |
| 217 | + |
| 218 | + return processed |
| 219 | +} |
| 220 | + |
| 221 | +/** |
| 222 | + * Create a signing request and display QR code |
| 223 | + */ |
| 224 | +export async function createActionRequest( |
| 225 | + contractAction: string, |
| 226 | + dataString: string, |
| 227 | + options: ActionRequestOptions |
| 228 | +): Promise<void> { |
| 229 | + // Parse contract::action format |
| 230 | + const {contractAccount, actionName} = parseContractAction(contractAction) |
| 231 | + |
| 232 | + // Parse action data |
| 233 | + const actionData = parseActionData(dataString) |
| 234 | + |
| 235 | + // Parse authorization |
| 236 | + const auth = parseAuthorization(options.auth) |
| 237 | + |
| 238 | + // Get the API URL |
| 239 | + const url = getApiUrl(options.chain || 'local') |
| 240 | + |
| 241 | + console.log('Creating signing request...') |
| 242 | + console.log(` Contract: ${contractAccount}`) |
| 243 | + console.log(` Action: ${actionName}`) |
| 244 | + console.log(` Chain: ${options.chain || 'local'} (${url})`) |
| 245 | + console.log(` Data: ${JSON.stringify(actionData, null, 2)}`) |
| 246 | + |
| 247 | + if (auth.actor) { |
| 248 | + console.log(` Authorization: ${auth.actor}@${auth.permission}`) |
| 249 | + } else { |
| 250 | + console.log(` Authorization: <wallet signer>@<wallet permission>`) |
| 251 | + } |
| 252 | + |
| 253 | + try { |
| 254 | + const client = new APIClient({ |
| 255 | + provider: new FetchProvider(url, {fetch}), |
| 256 | + }) |
| 257 | + |
| 258 | + const {uri} = await createActionESR(client, contractAccount, actionName, actionData, auth) |
| 259 | + |
| 260 | + displayQRCode(uri, `📝 ${contractAccount}::${actionName}`) |
| 261 | + |
| 262 | + console.log(`\n✅ Signing request created!`) |
| 263 | + console.log(` Scan the QR code with a compatible wallet to sign and broadcast.`) |
| 264 | + } catch (error) { |
| 265 | + console.error(`\n❌ Failed to create signing request: ${(error as Error).message}`) |
| 266 | + process.exit(1) |
| 267 | + } |
| 268 | +} |
0 commit comments