// Helper functions for interactive tutorials // Locale strings. TODO: maybe move these out to their own file. LOCALES = { en: { // Leave empty, use the keys provided (in English) by default }, ja: { 'Address:': 'アドレス:', 'Secret:': 'シード:', 'Balance:': '残高:', 'Complete all previous steps first': '前の手順をすべて完了して下さい', 'Connection to the XRP Ledger required': 'XRP Ledgerの接続が必要です', 'Error:': 'エラー:', "Populated this page's examples with these credentials.": 'このページの例にこのアドレスとシードを入力しました。', 'There was an error connecting to the Faucet. Please try again.': 'テストネットワークFaucetにエラーが発生しました。もう一度試してください。', 'Connecting...': '接続しています...', 'Connection required': '接続が必要です', Connected: '接続されました', 'Faucet returned an error:': 'テストネットワークFaucetがこのエラーを返しました:', Validated: '検証済み', 'Final Result:': '確定結果:', '(Still pending...)': '(まだ未決…)', '(None)': '(無)', 'Prepared transaction:': '準備済みトランザクション:', 'Failed to achieve consensus (final)': '検証済みレジャーには含まれません(決定結果)', 'Preliminary result:': '予備結果:', Unknown: '不明', "Couldn't get a valid address/secret value. Check that the previous steps were completed successfully.": '有効なアドレスかシードの取得出来ませんでした。前の手順が完了しましたことを確認して下さい。', 'Transaction hash:': 'トランザクションハッシュ:', }, } /** * Quick-n-dirty localization function. TODO: migrate to a real localization * library, such as https://github.com/wikimedia/jquery.i18n * @param {String} key The string to translate into this page's locale * @return {String} The translated string, if one is available, or the provided * key value if no translation is available. */ function tl(key) { let current_locale = $('html').prop('lang').substring(0, 2) if (!(current_locale in LOCALES)) { console.warn("Interactive tutorials don't have translations for locale:", current_locale) current_locale = 'en' } let mesg = LOCALES[current_locale][key] if (typeof mesg === 'undefined') { mesg = key } return mesg } /** * Convert a string of text into a "slug" form that is appropriate for use in * URLs, HTML id and class attributes, and similar. This matches the equivalent * function in @theme/helpers.js so that IDs can be found consistently. * This version is more Unicode-friendly than the old version ('slugify') * @param {String} s The text (step_name or similar) to convert into a * @return {String} The "slug" version of the text, lowercase with no whitespace * and with most non-alphanumeric characters removed. */ function idify(s) { // s = s.replace(/[^\p{Alphabetic}\p{Mark}\p{Decimal_Number}\p{Connector_Punctuation}\p{Join_Control}]/gu, '').trim().toLowerCase() s = s .replace(/([^\w]|[\s-])/gu, '') .trim() .toLowerCase() s = s.replace(/[\s-]+/gu, '-') if (!s) { s = '_' } return s } /** * Check whether a given step has been marked completed already. * @param {String} step_name The exact name of the step, as defined in the * start_step(step_name) function in the MD file. * @return {Boolean} Whether or not this step has been marked complete. */ function is_complete(step_name) { return is_complete_by_id(idify(step_name)) } /** * Helper for is_complete. Also called directly in cases where we only have * the step_id and not the step_name (since that's a one-way conversion). * @param {String} step_id The slugified name of the step. * @return {Boolean} Whether or not this step has been marked complete. */ function is_complete_by_id(step_id) { return $('.bc-' + step_id).hasClass('done') } /** * Mark a step as done in the breadcrumbs, mark the following step as active, * and enable buttons and such in the following step that have the * "previous-steps-required" class. * @param {String} step_name The exact name of the step, as defined in the * start_step(step_name) function in the MD file. */ function complete_step(step_name) { complete_step_by_id(idify(step_name)) } /** * Helper for complete_step. Also called directly in cases where we only have * the step_id and not the step_name (since that's a one-way conversion). * @param {String} step_id The slugified name of the step. */ function complete_step_by_id(step_id) { $('.bc-' + step_id) .removeClass('active') .addClass('done') $('.bc-' + step_id) .next() .removeClass('disabled') .addClass('active') // Enable follow-up steps that require this step to be done first const next_ui = $(`#interactive-${step_id}`).nextAll('.interactive-block').eq(0).find('.previous-steps-required') next_ui.prop('title', '') next_ui.prop('disabled', false) next_ui.removeClass('disabled') } /** * Get the step_id of the interactive block that contains a given element. * @param {jQuery} jEl The jQuery result representing a single HTML element in * an interactive block. * @return {String} The step_id of the block that contains jEl. */ function get_block_id(jEl) { // Traverse up, then slice "interactive-" off the block's HTML ID return jEl.closest('.interactive-block').prop('id').slice(12) } /** * Pretty-print JSON with standard indentation. * @param {String, Object} j Either a JSON/JSON-like object, or a string containing JSON. */ function pretty_print(j) { try { return JSON.stringify(JSON.parse(j), null, 2) } catch (e) { // probably already decoded JSON return JSON.stringify(j, null, 2) } } /** * Disable buttons and such with the "previous-steps-required" class, and give * them an appropriate tooltip message. */ function disable_followup_steps() { $('.previous-steps-required').prop('title', tl('Complete all previous steps first')) $('.previous-steps-required').prop('disabled', true).addClass('disabled') $('.connection-required').prop('title', tl('Conection to the XRP Ledger required')) $('.connection-required').prop('disabled', true).addClass('disabled') } /** * Output an error message in the given area. * @param {jQuery} block The block where this error message should go. This * should be a parent element containing an element with * the "output-area" class. * @param {String} message The HTML contents to put inside the message. */ function show_error(block, message) { block.find('.output-area').html( `

${tl('Error:')} ${message}

`, ) } /** * To be used with _snippets/interactive/tutorials/generate-step.md. * Adds an event to the "Generate" button to call the appropriate faucet * (Testnet or Devnet) and write the credentials to elements that later steps * can use in their examples. Also updates code samples in the current page to * use the generated credentials instead of the placeholder EXAMPLE_ADDR and * EXAMPLE_SECRET. */ var EXAMPLE_ADDR = 'rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe' var EXAMPLE_SECRET = 's████████████████████████████' function setup_generate_step() { $('#generate-creds-button').click(async (event) => { const block = $(event.target).closest('.interactive-block') block.find('.output-area').html('') block.find('.loader').show() // Get faucet URL (Testnet/Devnet/etc.) const faucet_url = $('#generate-creds-button').data('fauceturl') try { const wallet = xrpl.Wallet.generate('ed25519') const data = await call_faucet(faucet_url, wallet.address, event) block.find('.loader').hide() block.find('.output-area').html(`
${tl('Address:')} ${data.account.address}
${tl('Secret:')} ${wallet.seed}
`) if (data.balance) { block.find('.output-area').append(`
${tl('Balance:')} ${data.balance} XRP
`) } // Automatically populate all examples in the page with the // generated credentials... let creds_updated = false $("code span:contains('" + EXAMPLE_ADDR + "')").each(function () { creds_updated = true let eltext = $(this).text() $(this).text(eltext.replace(EXAMPLE_ADDR, data.account.address)) }) $("code span:contains('" + EXAMPLE_SECRET + "')").each(function () { creds_updated = true let eltext = $(this).text() $(this).text(eltext.replace(EXAMPLE_SECRET, data.account.secret)) }) if (creds_updated) { block.find('.output-area').append(`

${tl("Populated this page's examples with these credentials.")}

`) } complete_step('Generate') } catch (err) { block.find('.loader').hide() block.find('.output-area').html( `

${tl('Error:')} ${tl('There was an error connecting to the Faucet. Please try again.')}

`, ) return } }) } /** * Get the address from the Generate step (snippet), or display an error in * the relevant interactive block if it fails—usually because the user hasn't * clicked the "Get Credentials" button yet. * @return {String, undefined} The address, if available, or undefined if not */ function get_address(event) { const address = $('#use-address').text() if (!address) { const block = $(event.target).closest('.interactive-block') if (!block.length) { return } show_error(block, tl("Couldn't get a valid address/secret value. Check that the previous steps were completed successfully.")) } return address } /** * Get the Wallet from the Generate step (snippet), or display an error in * the relevant interactive block if it fails—usually because the user hasn't * clicked the "Get Credentials" button yet. * @return {Wallet, undefined} The Wallet instance, if available, or undefined if not */ function get_wallet(event) { const secret = $('#use-secret').text() if (!secret) { const block = $(event.target).closest('.interactive-block') if (!block.length) { return } show_error(block, tl("Couldn't get a valid address/secret value. Check that the previous steps were completed successfully.")) } if (secret == EXAMPLE_SECRET) { const block = $(event.target).closest('.interactive-block') if (!block.length) { return } show_error(block, tl("Can't use the example secret here. Check that the previous steps were completed successfully.")) return } return xrpl.Wallet.fromSeed(secret) } /** * Helper for calling the Testnet/Devnet faucet. * @param {String} faucet_url The URL of the faucet to call, for example: * https://faucet.altnet.rippletest.net/accounts * @param {String} destination The account to fund, if undefined, the faucet will create one * @param {Object} event The event object to get memo data from * */ async function call_faucet(faucet_url, destination, event) { // Future feature: support the Faucet's optional xrpAmount param const block = $(event.target).closest('.interactive-block') const tutorial_info = { path: window.location.pathname, button: event.target.id, step: block.data('stepnumber'), totalsteps: block.data('totalsteps'), } //pass in plain text instead of HEX- the API will encode. const memo = { data: JSON.stringify(tutorial_info, null, 0), format: 'application/json', // application/json // The MemoType decodes to a URL that explains the format of this memo type: // https://github.com/XRPLF/xrpl-dev-portal/blob/master/tool/INTERACTIVE_TUTORIALS_README.md type: 'https://github.com/XRPLF/xrpl-dev-portal/blob/master/tool/INTERACTIVE_TUTORIALS_README.md', } const body = {} body['destination'] = destination body['memos'] = [memo] const response = await fetch(faucet_url, { method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8', }, body: JSON.stringify(body), }) const data = await response.json() if (!response.ok) { throw `${tl('Faucet returned an error:')} ${data.error}` } return data } /** * Tutorials' scripts should push functions to this array to have them run * automatically after connecting to the network. The scopes don't work out * right to use api.on("connect", callback) directly from the tutorials' unique * scripts because the api instance (specific to the network) is instantiated * by the setup_connect_step() in this file, below. */ window.after_connect = window.after_connect || [] /** * To be used with _snippets/interactive-tutorials/connect-step.md * Adds an event to the "Connect" button to connect to the appropriate * WebSocket server (Testnet, Devnet, maybe Mainnet for some cases). * Also adds an event to re-disable following steps if we get disconnected. */ function setup_connect_step() { if (!$('#connect-button').length) { console.debug('Connect step not included. Skipping related setup.') return } const ws_url = $('#connect-button').data('wsurl') if (!ws_url) { console.error('Interactive Tutorial: WS URL not found. Did you set use_network?') return } const block = $('#connect-button').closest('.interactive-block') api = new xrpl.Client(ws_url) api.on('connected', async function () { $('#connection-status').text(tl('Connected')) $('#connect-button').prop('disabled', true) block.find('.loader').hide() $('.connection-required').prop('disabled', false) $('.connection-required').prop('title', '') // Subscribe to ledger close events api.request({ command: 'subscribe', streams: ['ledger'] }) complete_step('Connect') }) api.on('disconnected', (code) => { $('#connection-status').text(tl('Disconnected') + ' (' + code + ')') $('#connect-button').prop('disabled', false) $('.connection-required').prop('disabled', true) $('.connection-required').prop('title', tl('Connection required')) disable_followup_steps() }) $('#connect-button').click(async (event) => { $('#connection-status').text(tl('Connecting...')) block.find('.loader').show() await api.connect() for (const fn of after_connect) { fn() } }) } /** * To be used with _snippets/interactive-tutorials/wait-step.md * For each wait step in the page, set up a listener that checks for the * transaction shown in that step's "waiting-for-tx" field, as long as that * step's "tx-validation-status" field doesn't have a final result set. * These listeners do very little (just updating the latest validated ledger * index) until you activate one with activate_wait_step(step_name). * Requires xrpl.js to be loaded and instantiated as "api" first. */ function setup_wait_steps() { const wait_steps = $('.wait-step') wait_steps.each(async (i, el) => { const wait_step = $(el) const explorer_url = wait_step.data('explorerurl') const status_box = wait_step.find('.tx-validation-status') api.on('ledgerClosed', async (ledger) => { // Update the latest validated ledger index in this step's table wait_step.find('.validated-ledger-version').text(ledger.ledger_index) if (!status_box.data('status_pending')) { // Before submission or after a final result. // Either way, nothing more to do here. return } const transaction = wait_step.find('.waiting-for-tx').text().trim() const min_ledger = parseInt(wait_step.find('.earliest-ledger-version').text()) const max_ledger = parseInt(wait_step.find('.lastledgersequence').text()) let tx_response try { tx_response = await api.request({ command: 'tx', transaction, min_ledger, max_ledger, }) if (tx_response.result.validated) { status_box.html( `${tl('Final Result:')}${tx_response.result.meta.TransactionResult} (${tl('Validated')})`, ) const step_id = get_block_id(wait_step) if (!is_complete_by_id(step_id)) { status_box.data('status_pending', false) complete_step_by_id(step_id) } } else { status_box.html( `${tl('Final Result:')} ${tl('(Still pending...)')}`, ) } } catch (e) { if (e.data.error == 'txnNotFound' && e.data.searched_all) { status_box.html(`${tl('Final Result:')}${tl('Failed to achieve consensus (final)')}`) } else { status_box.html(`${tl('Final Result:')}${tl('Unknown')}`) } } }) // end 'ledger' event handler }) // end "each" wait_step } /** * To be used with _snippets/interactive-tutorials/wait-step.md * Populate the table of the wait step with the relevant transaction details * and signal this step's listener to look up the relevant transaction. * This function is called by the generic submit handlers that * make_submit_handler() creates. * @param {String} step_name The exact name of the "Wait" step to activate, as * defined in the start_step(step_name) function in * the MD file. * @param {Object} prelim The (resolved) return value of submitting a * transaction blob via api.request({command: "submit", opts}) */ async function activate_wait_step(step_name, prelim) { const step_id = idify(step_name) const wait_step = $(`#interactive-${step_id} .wait-step`) const status_box = wait_step.find('.tx-validation-status') const tx_id = prelim.result.tx_json.hash const lls = prelim.result.tx_json.LastLedgerSequence || tl('(None)') if (wait_step.find('.waiting-for-tx').text() == tx_id) { // Re-submitting the same transaction? Don't update min_ledger. } else { wait_step.find('.waiting-for-tx').text(tx_id) wait_step.find('.earliest-ledger-version').text(prelim.result.validated_ledger_index) } wait_step.find('.lastledgersequence').text(lls) status_box.html('') status_box.data('status_pending', true) } /** * Get the hexadecimal ASCII representation of a string (must contain only * 7-bit ASCII characters). * @param {String} s The string to encode. * @return {String} The uppercase hexadecimal representation of the string. */ function text_to_hex(s) { result = '' for (let i = 0; i < s.length; i++) { result += s.charCodeAt(i).toString(16) } return result.toUpperCase() } /** * Add a memo to transaction instructions (before signing) to indicate that this * transaction was generated by an interactive tutorial. This allows anyone to * observe transactions on Testnet/Devnet to see which ones originate from which * interactive tutorials. For privacy reasons, the memo does not and MUST NOT * include personally identifying information about the user or their browser. * @param {Object} event The click event that caused this transaction to be sent * @param {Object} tx_json The JSON transaction instructions to have the memo * added to them (in-place). */ function add_memo(event, tx_json) { const block = $(event.target).closest('.interactive-block') const tutorial_info = { path: window.location.pathname, button: event.target.id, step: block.data('stepnumber'), totalsteps: block.data('totalsteps'), } const memo = { Memo: { MemoData: text_to_hex(JSON.stringify(tutorial_info, null, 0)), MemoFormat: '6170706C69636174696F6E2F6A736F6E', // application/json // The MemoType decodes to a URL that explains the format of this memo type: // https://github.com/XRPLF/xrpl-dev-portal/blob/master/tool/INTERACTIVE_TUTORIALS_README.md MemoType: '68747470733A2F2F6769746875622E636F6D2F5852504C462F7872706C2D6465762D706F7274616C2F626C6F622F6D61737465722F746F6F6C2F494E5445524143544956455F5455544F5249414C535F524541444D452E6D64', }, } if (tx_json.Memos === undefined) { tx_json['Memos'] = [memo] } else { tx_json['Memos'].push(memo) } } /** * Helper for "Send Transaction" buttons to handle the full process of * Prepare → Sign → Submit in one step with appropriate outputs. Assumes you are * also using _snippets/interactive-tutorials/wait-step.md to report the * transaction's final results. Gets important information from data * attributes defined on the button that triggers the event: * data-tx-blob-from="{jQuery selector}" A selector for an element whose .text() * is the blob to submit. * data-wait-step-name="{String}" The exact name of the wait step where this * transaction's results should be reported, as * defined in start_step(step_name) as the MD * This function is meant to be called from within a .click(event) handler, not * directly bound as the click handler. * @param {Event} event The (click) event that this is helping to handle. * @param {Object} tx_json JSON object of transaction instructions to finish * preparing and send. * @param {Wallet} wallet (Optional) The xrpl.js Wallet instance to use to sign the * transaction. If omitted, look up the #use-secret field * which was probably added by a "Get Credentials" step. */ async function generic_full_send(event, tx_json, wallet) { const block = $(event.target).closest('.interactive-block') const blob_selector = $(event.target).data('txBlobFrom') const wait_step_name = $(event.target).data('waitStepName') block.find('.output-area').html('') if (wallet === undefined) { wallet = get_wallet(event) } if (!wallet) { return } add_memo(event, tx_json) block.find('.loader').show() const prepared = await api.autofill(tx_json) block.find('.output-area').append( `

${tl('Prepared transaction:')}

${pretty_print(prepared)}
`, ) const { tx_blob, hash } = wallet.sign(prepared) block.find('.output-area').append(`

${tl('Transaction hash:')} ${hash}

`) await do_submit(block, { tx_blob: tx_blob }, wait_step_name) } /** * Generic event handler for transaction submission buttons. Assumes * you are also using _snippets/interactive-tutorials/wait-step.md to report * the transaction's final results. Gets important information from data * attributes defined on the button that triggers the event: * data-tx-blob-from="{jQuery selector}" A selector for an element whose .text() * is the blob to submt. * data-wait-step-name="{String}" The exact name of the wait step where this * transaction's results should be reported, as * defined in start_step(step_name) as the MD * This function is intended to be bound directly on a submit button as the * click event handler. */ async function submit_handler(event) { const block = $(event.target).closest('.interactive-block') const blob_selector = $(event.target).data('txBlobFrom') const wait_step_name = $(event.target).data('waitStepName') const tx_blob = $(blob_selector).text() do_submit(block, { tx_blob }, wait_step_name) } /** * General-purpose transaction submission helper. * @param {jQuery} block Output preliminary results inside this wrapping * .interactive-block's .output-area. * @param {Object} submit_opts The JSON object to be passed to the rippled * submit command. At a minimum, needs "tx_blob" * @param {String} wait_step_name The name of a wait step. Report the final * results of the transaction there. Must be a * _snippets/interactive-tutorials/wait-step.md */ async function do_submit(block, submit_opts, wait_step_name) { block.find('.loader').show() try { submit_opts['command'] = 'submit' const prelim_result = await api.request(submit_opts) block.find('.output-area').append( `

${tl('Preliminary result:')}

${pretty_print(prelim_result)}
`, ) block.find('.loader').hide() submit_step_id = get_block_id(block) complete_step_by_id(submit_step_id) if (wait_step_name) { activate_wait_step(wait_step_name, prelim_result) } return prelim_result } catch (error) { block.find('.loader').hide() show_error(block, error) } } async function show_log(block, msg) { block.find('.output-area').append(msg) } /** * Run callback only when the current route is loaded. */ function onCurrentRouteLoaded(callback) { const currentPath = window.location.pathname window.onRouteChange(() => { if (window.location.pathname === currentPath) { callback() } }) } window.onRouteChange(() => { disable_followup_steps() setup_generate_step() setup_connect_step() setup_wait_steps() })