unified-deploy.ts
1 #!/usr/bin/env bun 2 /** 3 * Unified Tableland Deployment Script 4 * 5 * Features: 6 * - Support for all networks (mainnet and testnet) 7 * - Private key and Ledger signing support 8 * - Batch deployments 9 * - Deployment verification 10 * - Automated deployment tracking 11 * - Dry run mode 12 */ 13 14 import { Database } from '@tableland/sdk' 15 import { Wallet, ethers } from 'ethers' 16 import HIDTransport from '@ledgerhq/hw-transport-node-hid' 17 import Eth from '@ledgerhq/hw-app-eth' 18 import { TABLELAND_CONFIG, type TableName, type NetworkName } from '../config' 19 import dotenv from 'dotenv' 20 import ora from 'ora' 21 import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' 22 import { join, dirname } from 'path' 23 import { fileURLToPath } from 'url' 24 25 // Load environment variables 26 const __dirname = dirname(fileURLToPath(import.meta.url)) 27 dotenv.config({ path: join(__dirname, '../../.env') }) 28 if (!process.env.PRIVATE_KEY) { 29 dotenv.config({ path: join(__dirname, '../.env') }) 30 } 31 32 // Types 33 interface DeploymentOptions { 34 tableName: TableName 35 network: NetworkName 36 useLedger?: boolean 37 derivationPath?: string 38 dryRun?: boolean 39 verify?: boolean 40 waitTime?: number 41 } 42 43 interface DeploymentResult { 44 success: boolean 45 tableName?: string 46 transactionHash?: string 47 error?: string 48 deploymentInfo?: any 49 } 50 51 // Custom Ledger Signer for ethers v6 52 class LedgerSignerV6 extends ethers.AbstractSigner { 53 private eth: Eth 54 private path: string 55 private _address: string | null = null 56 57 constructor(eth: Eth, path: string, provider?: ethers.Provider) { 58 super(provider) 59 this.eth = eth 60 this.path = path 61 } 62 63 async getAddress(): Promise<string> { 64 if (!this._address) { 65 const { address } = await this.eth.getAddress(this.path) 66 this._address = address 67 } 68 return this._address 69 } 70 71 async signTransaction(tx: ethers.TransactionRequest): Promise<string> { 72 const populatedTx = await this.populateTransaction(tx) 73 74 const unsignedTx = { 75 to: populatedTx.to, 76 value: populatedTx.value || 0n, 77 data: populatedTx.data || '0x', 78 chainId: populatedTx.chainId, 79 nonce: populatedTx.nonce, 80 gasLimit: populatedTx.gasLimit, 81 maxFeePerGas: populatedTx.maxFeePerGas, 82 maxPriorityFeePerGas: populatedTx.maxPriorityFeePerGas, 83 type: 2 84 } 85 86 console.log('\nā” Please approve the transaction on your Ledger device') 87 88 const ethTx = ethers.Transaction.from(unsignedTx) 89 const signature = await this.eth.signTransaction( 90 this.path, 91 ethTx.unsignedSerialized.substring(2), 92 null 93 ) 94 95 ethTx.signature = { 96 r: '0x' + signature.r, 97 s: '0x' + signature.s, 98 v: parseInt(signature.v, 16) 99 } 100 101 return ethTx.serialized 102 } 103 104 async signMessage(message: string | Uint8Array): Promise<string> { 105 const messageHex = typeof message === 'string' 106 ? Buffer.from(message).toString('hex') 107 : Buffer.from(message).toString('hex') 108 109 const signature = await this.eth.signPersonalMessage(this.path, messageHex) 110 return '0x' + signature.r + signature.s + signature.v 111 } 112 113 connect(provider: ethers.Provider): LedgerSignerV6 { 114 return new LedgerSignerV6(this.eth, this.path, provider) 115 } 116 117 async signTypedData( 118 domain: ethers.TypedDataDomain, 119 types: Record<string, ethers.TypedDataField[]>, 120 value: Record<string, any> 121 ): Promise<string> { 122 throw new Error('signTypedData not implemented for Ledger') 123 } 124 } 125 126 // Deployment tracking functions 127 function getDeploymentPath(network: NetworkName): string { 128 const isMainnet = network.includes('mainnet') 129 return join(__dirname, '../deployments', isMainnet ? 'mainnet' : 'testnet', `${network.replace('-', '_')}.json`) 130 } 131 132 function loadDeployments(network: NetworkName): any { 133 const path = getDeploymentPath(network) 134 if (existsSync(path)) { 135 return JSON.parse(readFileSync(path, 'utf8')) 136 } 137 return { tables: {}, lastUpdated: null } 138 } 139 140 function saveDeployment(network: NetworkName, tableName: TableName, deploymentInfo: any): void { 141 const path = getDeploymentPath(network) 142 const dir = dirname(path) 143 144 if (!existsSync(dir)) { 145 mkdirSync(dir, { recursive: true }) 146 } 147 148 const deployments = loadDeployments(network) 149 if (!deployments.tables) deployments.tables = {} 150 151 deployments.tables[tableName] = deploymentInfo 152 deployments.lastUpdated = new Date().toISOString() 153 154 writeFileSync(path, JSON.stringify(deployments, null, 2)) 155 } 156 157 // Main deployment function 158 async function deployTable(options: DeploymentOptions): Promise<DeploymentResult> { 159 const { tableName, network, useLedger = false, derivationPath, dryRun = false, verify = true, waitTime = 30 } = options 160 const config = TABLELAND_CONFIG.networks[network] 161 const schema = TABLELAND_CONFIG.schemas[tableName] 162 163 const spinner = ora({ 164 text: `Preparing to deploy ${tableName} on ${network}...`, 165 spinner: 'dots' 166 }).start() 167 168 let signer: ethers.Signer 169 let transport: any = null 170 171 try { 172 // Setup provider 173 const provider = new ethers.JsonRpcProvider(config.rpcUrl) 174 175 // Setup signer 176 if (useLedger) { 177 const path = derivationPath || "m/44'/60'/0'/0/0" 178 spinner.text = 'Connecting to Ledger...' 179 180 transport = await HIDTransport.create() 181 const eth = new Eth(transport) 182 signer = new LedgerSignerV6(eth, path, provider) 183 184 const address = await signer.getAddress() 185 spinner.succeed(`Connected to Ledger: ${address}`) 186 } else { 187 if (!process.env.PRIVATE_KEY) { 188 throw new Error('PRIVATE_KEY not found in environment variables') 189 } 190 191 const privateKey = process.env.PRIVATE_KEY.startsWith('0x') 192 ? process.env.PRIVATE_KEY 193 : `0x${process.env.PRIVATE_KEY}` 194 signer = new Wallet(privateKey, provider) 195 } 196 197 // Check balance 198 const address = await signer.getAddress() 199 const balance = await provider.getBalance(address) 200 console.log(`š° Balance: ${ethers.formatEther(balance)} ETH`) 201 202 if (balance === 0n) { 203 throw new Error('Insufficient balance for deployment') 204 } 205 206 // Check for existing deployment 207 const existingDeployments = loadDeployments(network) 208 if (existingDeployments.tables?.[tableName]) { 209 console.log('\nā ļø Warning: Table already deployed on this network:') 210 console.log(` Table: ${existingDeployments.tables[tableName].tableName}`) 211 console.log(` Deployed: ${existingDeployments.tables[tableName].deployedAt}`) 212 console.log(` Transaction: ${existingDeployments.tables[tableName].transactionHash}`) 213 214 if (!dryRun) { 215 const readline = await import('readline') 216 const rl = readline.createInterface({ 217 input: process.stdin, 218 output: process.stdout 219 }) 220 221 const answer = await new Promise<string>((resolve) => { 222 rl.question('\nDo you want to continue? (y/N): ', resolve) 223 }) 224 rl.close() 225 226 if (answer.toLowerCase() !== 'y') { 227 spinner.info('Deployment cancelled') 228 return { success: false, error: 'Deployment cancelled by user' } 229 } 230 } 231 } 232 233 if (dryRun) { 234 spinner.info('DRY RUN - No actual deployment will occur') 235 console.log('\nš Deployment Plan:') 236 console.log(` Network: ${network} (Chain ID: ${config.chainId})`) 237 console.log(` Table: ${tableName} (${schema.prefix}_${config.chainId})`) 238 console.log(` Signer: ${address}`) 239 console.log(` Schema Version: ${schema.version}`) 240 console.log('\nš Table Schema:') 241 console.log(schema.schema) 242 243 return { success: true } 244 } 245 246 // Create database instance 247 spinner.start('Deploying table...') 248 const dbConfig: any = { signer } 249 250 // For Base mainnet, explicitly set the registry contract 251 if (network === 'base-mainnet') { 252 dbConfig.baseUrl = config.tablelandHost 253 dbConfig.contract = '0x8268F7Aba0E152B3A853e8CB4Ab9795Ec66c2b6B' 254 } 255 256 const db = new Database(dbConfig) 257 258 // Deploy table 259 const { meta } = await db 260 .prepare(`CREATE TABLE ${schema.prefix}_${config.chainId} ${schema.schema}`) 261 .run() 262 263 spinner.succeed('Table deployment transaction sent!') 264 console.log(`š Transaction: ${meta.txn?.transactionHash}`) 265 266 // Wait for confirmation 267 spinner.start('Waiting for transaction confirmation...') 268 let deployedTableName: string 269 270 try { 271 await meta.txn?.wait() 272 deployedTableName = meta.txn?.names?.[0] || `${schema.prefix}_${config.chainId}_${meta.txn?.tableId}` 273 spinner.succeed('Transaction confirmed!') 274 } catch (e) { 275 deployedTableName = `${schema.prefix}_${config.chainId}_${meta.txn?.tableId}` 276 spinner.warn('Transaction wait timeout (but likely succeeded)') 277 } 278 279 console.log(`š Table Name: ${deployedTableName}`) 280 281 // Save deployment info 282 const deploymentInfo = { 283 network, 284 chainId: config.chainId, 285 tableName: deployedTableName, 286 transactionHash: meta.txn?.transactionHash, 287 deployedAt: new Date().toISOString(), 288 deployedBy: address, 289 schema: schema.schema, 290 version: schema.version 291 } 292 293 saveDeployment(network, tableName, deploymentInfo) 294 spinner.succeed('Deployment info saved!') 295 296 // Verify deployment 297 if (verify) { 298 spinner.start(`Waiting ${waitTime} seconds for Tableland to sync...`) 299 await new Promise(resolve => setTimeout(resolve, waitTime * 1000)) 300 301 spinner.text = 'Verifying deployment...' 302 303 try { 304 const result = await db.prepare(`SELECT * FROM ${deployedTableName} LIMIT 1`).all() 305 spinner.succeed(`Deployment verified! Table ${deployedTableName} is accessible`) 306 } catch (e) { 307 spinner.warn('Could not verify deployment (table may still be syncing)') 308 } 309 } 310 311 return { 312 success: true, 313 tableName: deployedTableName, 314 transactionHash: meta.txn?.transactionHash, 315 deploymentInfo 316 } 317 318 } catch (error: any) { 319 spinner.fail(`Deployment failed: ${error.message}`) 320 return { success: false, error: error.message } 321 } finally { 322 if (transport) { 323 await transport.close() 324 } 325 } 326 } 327 328 // Batch deployment function 329 async function batchDeploy( 330 tables: TableName[], 331 networks: NetworkName[], 332 options: Partial<DeploymentOptions> = {} 333 ): Promise<void> { 334 console.log('š Starting batch deployment...\n') 335 336 const results: any[] = [] 337 338 for (const network of networks) { 339 for (const tableName of tables) { 340 console.log(`\nš¦ Deploying ${tableName} on ${network}...`) 341 342 const result = await deployTable({ 343 tableName, 344 network, 345 ...options 346 }) 347 348 results.push({ 349 tableName, 350 network, 351 ...result 352 }) 353 354 if (!result.success) { 355 console.error(`ā Failed to deploy ${tableName} on ${network}: ${result.error}`) 356 } 357 } 358 } 359 360 // Summary 361 console.log('\nš Batch Deployment Summary:') 362 console.log('=' * 50) 363 364 const successful = results.filter(r => r.success) 365 const failed = results.filter(r => !r.success) 366 367 console.log(`ā Successful: ${successful.length}`) 368 console.log(`ā Failed: ${failed.length}`) 369 370 if (successful.length > 0) { 371 console.log('\nSuccessful deployments:') 372 successful.forEach(r => { 373 console.log(` - ${r.tableName} on ${r.network}: ${r.tableName}`) 374 }) 375 } 376 377 if (failed.length > 0) { 378 console.log('\nFailed deployments:') 379 failed.forEach(r => { 380 console.log(` - ${r.tableName} on ${r.network}: ${r.error}`) 381 }) 382 } 383 } 384 385 // CLI Command Parser 386 interface CliCommand { 387 command: string 388 description: string 389 handler: (args: string[]) => Promise<void> 390 } 391 392 const commands: CliCommand[] = [ 393 { 394 command: 'deploy', 395 description: 'Deploy a single table', 396 handler: async (args) => { 397 if (args.length < 2) { 398 console.error('Usage: deploy <table-name> <network> [options]') 399 console.error('Options:') 400 console.error(' --ledger Use Ledger for signing') 401 console.error(' --path <path> Ledger derivation path') 402 console.error(' --dry-run Show deployment plan without executing') 403 console.error(' --no-verify Skip deployment verification') 404 console.error(' --wait <seconds> Wait time before verification (default: 30)') 405 process.exit(1) 406 } 407 408 const tableName = args[0] as TableName 409 const network = args[1] as NetworkName 410 411 // Parse options 412 const options: DeploymentOptions = { 413 tableName, 414 network, 415 useLedger: args.includes('--ledger'), 416 dryRun: args.includes('--dry-run'), 417 verify: !args.includes('--no-verify'), 418 } 419 420 const pathIndex = args.indexOf('--path') 421 if (pathIndex !== -1 && args[pathIndex + 1]) { 422 options.derivationPath = args[pathIndex + 1] 423 } 424 425 const waitIndex = args.indexOf('--wait') 426 if (waitIndex !== -1 && args[waitIndex + 1]) { 427 options.waitTime = parseInt(args[waitIndex + 1]) 428 } 429 430 const result = await deployTable(options) 431 432 if (result.success) { 433 console.log('\n⨠Deployment successful!') 434 if (result.deploymentInfo) { 435 console.log('\nš Deployment Details:') 436 console.log(JSON.stringify(result.deploymentInfo, null, 2)) 437 } 438 } else { 439 console.error('\nā Deployment failed!') 440 process.exit(1) 441 } 442 } 443 }, 444 { 445 command: 'batch', 446 description: 'Deploy multiple tables', 447 handler: async (args) => { 448 if (args.length < 2) { 449 console.error('Usage: batch <tables> <networks> [options]') 450 console.error('Examples:') 451 console.error(' batch songs,user_history base-mainnet') 452 console.error(' batch all base-mainnet,base-sepolia --dry-run') 453 process.exit(1) 454 } 455 456 // Parse tables 457 let tables: TableName[] 458 if (args[0] === 'all') { 459 tables = Object.keys(TABLELAND_CONFIG.schemas) as TableName[] 460 } else { 461 tables = args[0].split(',') as TableName[] 462 } 463 464 // Parse networks 465 const networks = args[1].split(',') as NetworkName[] 466 467 // Parse options 468 const options: Partial<DeploymentOptions> = { 469 useLedger: args.includes('--ledger'), 470 dryRun: args.includes('--dry-run'), 471 verify: !args.includes('--no-verify'), 472 } 473 474 await batchDeploy(tables, networks, options) 475 } 476 }, 477 { 478 command: 'list', 479 description: 'List deployed tables', 480 handler: async (args) => { 481 const network = args[0] as NetworkName 482 483 if (!network) { 484 // List all deployments 485 console.log('š All Deployments:\n') 486 487 for (const net of Object.keys(TABLELAND_CONFIG.networks) as NetworkName[]) { 488 const deployments = loadDeployments(net) 489 490 if (deployments.tables && Object.keys(deployments.tables).length > 0) { 491 console.log(`\n${net}:`) 492 for (const [table, info] of Object.entries(deployments.tables)) { 493 console.log(` - ${table}: ${(info as any).tableName}`) 494 } 495 } 496 } 497 } else { 498 // List specific network 499 const deployments = loadDeployments(network) 500 501 if (!deployments.tables || Object.keys(deployments.tables).length === 0) { 502 console.log(`No deployments found for ${network}`) 503 return 504 } 505 506 console.log(`\nš Deployments on ${network}:\n`) 507 508 for (const [table, info] of Object.entries(deployments.tables)) { 509 const deployment = info as any 510 console.log(`${table}:`) 511 console.log(` Table Name: ${deployment.tableName}`) 512 console.log(` Deployed: ${deployment.deployedAt}`) 513 console.log(` Transaction: ${deployment.transactionHash}`) 514 console.log(` Deployer: ${deployment.deployedBy}`) 515 console.log(` Version: ${deployment.version || 'unknown'}`) 516 console.log() 517 } 518 } 519 } 520 }, 521 { 522 command: 'verify', 523 description: 'Verify a deployed table', 524 handler: async (args) => { 525 if (args.length < 2) { 526 console.error('Usage: verify <table-name> <network>') 527 process.exit(1) 528 } 529 530 const tableName = args[0] as TableName 531 const network = args[1] as NetworkName 532 533 const deployments = loadDeployments(network) 534 const deployment = deployments.tables?.[tableName] 535 536 if (!deployment) { 537 console.error(`No deployment found for ${tableName} on ${network}`) 538 process.exit(1) 539 } 540 541 const config = TABLELAND_CONFIG.networks[network] 542 const provider = new ethers.JsonRpcProvider(config.rpcUrl) 543 544 // Use a read-only database instance 545 const db = new Database() 546 547 try { 548 console.log(`\nš Verifying ${deployment.tableName}...`) 549 550 // Try to query the table 551 const result = await db.prepare(`SELECT COUNT(*) as count FROM ${deployment.tableName}`).first() 552 console.log(`ā Table is accessible! Row count: ${result?.count || 0}`) 553 554 // Check transaction 555 if (deployment.transactionHash) { 556 const receipt = await provider.getTransactionReceipt(deployment.transactionHash) 557 if (receipt) { 558 console.log(`ā Transaction confirmed in block ${receipt.blockNumber}`) 559 } 560 } 561 562 } catch (error: any) { 563 console.error(`ā Verification failed: ${error.message}`) 564 process.exit(1) 565 } 566 } 567 } 568 ] 569 570 // Main CLI handler 571 async function main() { 572 const args = process.argv.slice(2) 573 574 if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { 575 console.log('Unified Tableland Deployment Tool\n') 576 console.log('Usage: bun run unified-deploy.ts <command> [options]\n') 577 console.log('Commands:') 578 579 commands.forEach(cmd => { 580 console.log(` ${cmd.command.padEnd(10)} ${cmd.description}`) 581 }) 582 583 console.log('\nExamples:') 584 console.log(' bun run unified-deploy.ts deploy songs base-mainnet') 585 console.log(' bun run unified-deploy.ts deploy songs base-mainnet --ledger') 586 console.log(' bun run unified-deploy.ts batch all base-sepolia --dry-run') 587 console.log(' bun run unified-deploy.ts list') 588 console.log(' bun run unified-deploy.ts verify songs base-mainnet') 589 590 process.exit(0) 591 } 592 593 const commandName = args[0] 594 const command = commands.find(c => c.command === commandName) 595 596 if (!command) { 597 console.error(`Unknown command: ${commandName}`) 598 console.error('Run with --help for usage information') 599 process.exit(1) 600 } 601 602 try { 603 await command.handler(args.slice(1)) 604 } catch (error: any) { 605 console.error('Fatal error:', error.message) 606 if (error.stack) { 607 console.error(error.stack) 608 } 609 process.exit(1) 610 } 611 } 612 613 // Run if executed directly 614 if (import.meta.url === `file://${process.argv[1]}`) { 615 main() 616 } 617 618 // Export for programmatic use 619 export { deployTable, batchDeploy, DeploymentOptions, DeploymentResult }