/ tableland / deploy / unified-deploy.ts
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 }