setup.js 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. import 'dotenv/config';
  2. import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
  3. import path from 'path';
  4. import { fileURLToPath } from 'url';
  5. import crypto from 'crypto';
  6. import {
  7. Keypair,
  8. Address,
  9. TransactionBuilder,
  10. BASE_FEE,
  11. Networks,
  12. Operation,
  13. rpc,
  14. xdr,
  15. StrKey,
  16. } from '@stellar/stellar-sdk';
  17. console.log('###################### Initializing (SDK) ########################');
  18. const __filename = fileURLToPath(import.meta.url);
  19. const dirname = path.dirname(__filename);
  20. // --- Network config (mirrors your CLI) ---
  21. const RPC_URL = process.env.SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org';
  22. const NETWORK_PASSPHRASE = Networks.TESTNET;
  23. // --- Paths ---
  24. const CONTRACT_IDS_DIR = path.join(dirname, '.stellar', 'contract-ids');
  25. const ALICE_FILE = path.join(dirname, 'alice.txt'); // tests expect seed-only here
  26. // --- SDK server ---
  27. const server = new rpc.Server(RPC_URL);
  28. // ---------- helpers ----------
  29. const filenameNoExtension = (filename) => path.basename(filename, path.extname(filename));
  30. function logStep(s) {
  31. console.log(`\n=== ${s} ===`);
  32. }
  33. // Extract a valid Ed25519 seed ("S..." StrKey) from any string; return null if not found
  34. function extractSeed(raw) {
  35. if (!raw) return null;
  36. const text = String(raw).trim();
  37. // 1) Common "secret: S..." format
  38. const line = text.match(/^secret:\s*(\S+)/mi)?.[1];
  39. if (line && line.startsWith('S')) return line;
  40. // 2) Look for any S... seed inside the text (base32 chars, total length 56)
  41. const m = text.match(/\bS[ABCDEFGHIJKLMNOPQRSTUVWXYZ234567]{55}\b/);
  42. if (m) return m[0];
  43. // 3) Maybe the whole file/env is just the seed
  44. if (text.startsWith('S') && text.length >= 56) return text.split(/\s+/)[0];
  45. return null;
  46. }
  47. // Save alice in the legacy format expected by your tests: ONLY the secret seed.
  48. function saveAliceTxtSeedOnly(kp) {
  49. writeFileSync(ALICE_FILE, kp.secret().trim() + '\n');
  50. }
  51. // create/fund or reuse an account named "alice"
  52. async function getAlice() {
  53. // prefer env override if you want to reuse a key (optional)
  54. const envRaw = process.env.ALICE_SECRET?.trim();
  55. if (envRaw) {
  56. const seed = extractSeed(envRaw);
  57. if (!seed) throw new Error('ALICE_SECRET is set but not a valid S… seed');
  58. const kp = Keypair.fromSecret(seed);
  59. await server.requestAirdrop(kp.publicKey()).catch(() => {}); // no-op if already funded
  60. saveAliceTxtSeedOnly(kp); // normalize file for tests
  61. return kp;
  62. }
  63. // if we already wrote alice.txt, parse/normalize it (supports multi-line legacy)
  64. if (existsSync(ALICE_FILE)) {
  65. const raw = readFileSync(ALICE_FILE, 'utf8');
  66. const seed = extractSeed(raw);
  67. if (seed) {
  68. const kp = Keypair.fromSecret(seed);
  69. await server.requestAirdrop(kp.publicKey()).catch(() => {});
  70. // normalize file to seed-only so future runs & tests are stable
  71. saveAliceTxtSeedOnly(kp);
  72. return kp;
  73. }
  74. // fall through if file was malformed
  75. }
  76. // otherwise generate & fund
  77. const kp = Keypair.random();
  78. logStep(`Funding ${kp.publicKey()} via Friendbot`);
  79. await server.requestAirdrop(kp.publicKey());
  80. saveAliceTxtSeedOnly(kp);
  81. return kp;
  82. }
  83. async function loadSourceAccount(publicKey) {
  84. // For Soroban you fetch sequence via RPC:
  85. return server.getAccount(publicKey);
  86. }
  87. // Upload a WASM module (on-chain code). We also compute its SHA-256 (wasmHash) locally.
  88. async function uploadWasm(sourceAccount, signer, wasmBytes) {
  89. const tx = new TransactionBuilder(sourceAccount, {
  90. fee: BASE_FEE,
  91. networkPassphrase: NETWORK_PASSPHRASE,
  92. })
  93. .addOperation(Operation.uploadContractWasm({ wasm: wasmBytes }))
  94. .setTimeout(60)
  95. .build();
  96. // prepare (simulate adds resources/footprint), sign, send
  97. const prepared = await server.prepareTransaction(tx);
  98. prepared.sign(signer);
  99. const sent = await server.sendTransaction(prepared);
  100. await server.pollTransaction(sent.hash);
  101. // The wasmHash is the SHA-256 of the bytes; createContract expects this hash.
  102. const wasmHash = crypto.createHash('sha256').update(wasmBytes).digest(); // Buffer(32)
  103. return wasmHash;
  104. }
  105. // Extract the simulation return value (ScVal), supporting both parsed and base64 shapes
  106. function extractSimRetval(sim) {
  107. const candidate = sim?.result?.retval ?? sim?.results?.[0]?.retval;
  108. if (!candidate) return null;
  109. // Parsed object (xdr.ScVal): has a .switch() function (and often .toXDR())
  110. if (candidate && typeof candidate.switch === 'function') return candidate;
  111. // Base64-encoded XDR string (older shapes)
  112. if (typeof candidate === 'string') return xdr.ScVal.fromXDR(candidate, 'base64');
  113. // xdr object with toXDR method (rare edge)
  114. if (candidate && typeof candidate.toXDR === 'function') return candidate;
  115. return null;
  116. }
  117. // Create a contract instance from the uploaded wasmHash.
  118. // Returns the "C..." contract id using simulation (no event parsing).
  119. async function createContract(sourceAccount, signer, wasmHash) {
  120. const deployer = new Address(signer.publicKey());
  121. const salt = crypto.randomBytes(32); // deterministic ID for this deployer+salt
  122. // Build the tx (not prepared yet)
  123. let createTx = new TransactionBuilder(sourceAccount, {
  124. fee: BASE_FEE,
  125. networkPassphrase: NETWORK_PASSPHRASE,
  126. })
  127. .addOperation(
  128. Operation.createCustomContract({
  129. address: deployer,
  130. wasmHash, // sha256(wasm bytes)
  131. constructorArgs: [], // add args here if your contract has an init
  132. salt, // deterministic contract id
  133. })
  134. )
  135. .setTimeout(60)
  136. .build();
  137. // 1) SIMULATE to read the return value (contract address) before submitting
  138. const sim = await server.simulateTransaction(createTx);
  139. const scv = extractSimRetval(sim);
  140. if (!scv) {
  141. throw new Error(
  142. `simulateTransaction returned no retval for createCustomContract: ${JSON.stringify(sim)}`
  143. );
  144. }
  145. if (scv.switch() !== xdr.ScValType.scvAddress()) {
  146. throw new Error('createCustomContract retval is not an Address ScVal');
  147. }
  148. const scAddr = scv.address();
  149. if (scAddr.switch() !== xdr.ScAddressType.scAddressTypeContract()) {
  150. throw new Error('createCustomContract retval Address is not a contract');
  151. }
  152. const contractId = StrKey.encodeContract(scAddr.contractId()); // => "C..."
  153. // 2) Prepare, sign, send, poll
  154. createTx = await server.prepareTransaction(createTx);
  155. createTx.sign(signer);
  156. const sent = await server.sendTransaction(createTx);
  157. await server.pollTransaction(sent.hash);
  158. return contractId;
  159. }
  160. async function deployOne(wasmPath, signer) {
  161. const name = filenameNoExtension(wasmPath);
  162. const outFile = path.join(CONTRACT_IDS_DIR, `${name}.txt`);
  163. const wasmBytes = readFileSync(wasmPath);
  164. logStep(`Uploading WASM: ${wasmPath}`);
  165. let account = await loadSourceAccount(signer.publicKey());
  166. const wasmHash = await uploadWasm(account, signer, wasmBytes);
  167. logStep(`Creating contract for: ${name}`);
  168. account = await loadSourceAccount(signer.publicKey()); // refresh sequence
  169. const contractId = await createContract(account, signer, wasmHash);
  170. mkdirSync(CONTRACT_IDS_DIR, { recursive: true });
  171. writeFileSync(outFile, contractId + '\n');
  172. console.log(`✔ Wrote contract id -> ${outFile}`);
  173. }
  174. async function deployAll() {
  175. const signer = await getAlice();
  176. const files = readdirSync(dirname).filter((f) => f.endsWith('.wasm'));
  177. // include your Rust artifact, same path you used before
  178. const rustWasm = path.join(
  179. 'rust',
  180. 'target',
  181. 'wasm32v1-none',
  182. 'release-with-logs',
  183. 'hello_world.wasm'
  184. );
  185. if (!files.includes(rustWasm)) files.push(rustWasm);
  186. console.log('Found WASM files:', files);
  187. for (const f of files) {
  188. const full = path.join(dirname, f);
  189. await deployOne(full, signer);
  190. }
  191. }
  192. (async function main() {
  193. logStep('Network');
  194. console.log('RPC:', RPC_URL);
  195. console.log('Passphrase:', NETWORK_PASSPHRASE);
  196. await deployAll();
  197. })().catch((e) => {
  198. console.error('\nDeployment failed:', e?.response ?? e);
  199. process.exit(1);
  200. });