KRC20 Market Widget
October 23rd, 2024
/*
KRC20 Market Widget / KRC20 市场小部件

[English]
Introduction:
This is a Scriptable widget script for displaying KRC20 token market information, KAS price, network hashrate, and Gas fees.

Usage:
1. Copy this script into the Scriptable app.(https://apps.apple.com/us/app/scriptable/id1405459188)
2. Add a Scriptable widget(Large) to your iOS home screen.
3. Edit the widget and select this script(Copy the entire post).
4. Optional: Enter custom token symbols in the widget parameter, separated by commas (e.g., KASPY,KASPER,KASJAK).

Features:
- Display price, change, and market cap for multiple KRC20 tokens.
- Show KAS price and 24-hour change.
- Display current Kaspa network hashrate.
- Show real-time Gas fee estimates.
- Display the latest listed KRC20 tokens.

Data sources: Kaspa API, Kasplex API, and KSPR API

Disclaimer:
This script is for educational and reference purposes only. The author is not responsible for any losses or damages resulting from the use of this script.
The cryptocurrency market is highly risky; please invest cautiously. The information provided by this script does not constitute investment advice.
By using this script, you agree to assume all risks associated with its use. Please comply with relevant laws and regulations in your region.

Note on Gas Fees:
The displayed Gas fees are calculated based on a default transaction mass of 1000. Actual fees may vary significantly depending on the specific transaction. These estimates should not be considered as definitive references for real-world transactions.

[中文]
简介:
这是一个用于 Scriptable 的小部件脚本,用于显示 KRC20 代币的市场信息、KAS 价格、网络算力和 Gas 费用。

使用方法:
1. 将此脚本复制到 Scriptable 应用中。(https://apps.apple.com/cn/app/scriptable/id1405459188)
2. 在 iOS 主屏幕上添加一个 Scriptable 小部件(最大号)。
3. 编辑小部件,选择此脚本(复制本篇全部)。
4. 可选:在小部件参数中输入自定义代币符号,用逗号分隔(例如:KASPY,KASPER,KONAN)。

功能:
- 显示多个 KRC20 代币的价格、涨跌幅和市值。
- 显示 KAS 价格和 24 小时涨跌幅。
- 显示 Kaspa 网络当前算力。
- 显示实时 Gas 费用估算。
- 显示最新上线的 KRC20 代币。

数据来源:Kaspa API、Kasplex API 和 KSPR API

免责声明:
本脚本仅供学习和参考使用。作者不对因使用本脚本而导致的任何损失或损害承担责任。
加密货币市场具有高度风险性,请谨慎投资。本脚本提供的信息不构成投资建议。
使用本脚本即表示您同意自行承担使用风险。请遵守您所在地区的相关法律法规。

关于 Gas 费用的说明:
显示的 Gas 费用是基于默认交易质量(mass)为 1000 计算的。实际费用可能因具体交易而有显著差异。这些估算不应被视为实际交易的确定性参考。

Author / 作者:@Dodo13080274 (https://x.com/Dodo13080274)
Version / 版本:1.0
Last Updated / 最后更新:2024-10-23

Donation Address / 捐赠地址:
KAS: kaspa:qzlmuw020twqrk8d42nwhumqnza4pneyyx0ul5axg6v3822jvxu2g5gjcenfk
*/
let widget = new ListWidget()
let gradient = new LinearGradient()

gradient.colors = [new Color("#0f1c4d"), new Color("#3b1d6f")]
gradient.locations = [0.0, 1.0]
widget.backgroundGradient = gradient

// Color constants
const COLOR_WHITE = new Color("#FFFFFF")
const COLOR_LIGHT_BLUE = new Color("#A0B8FF")
const COLOR_LIGHT_PURPLE = new Color("#C8A2FF")
const COLOR_GREEN = new Color("#4CAF50")
const COLOR_RED = new Color("#FF5252")
const COLOR_GOLD = new Color("#FFD700")
const COLOR_BLUE = new Color("#1E90FF")
const COLOR_ORANGE = new Color("#FFA500")

// Language options
const languages = {
  "中文": {
    title: "KRC20 市场",
    price: " ",
    change: " ",
    marketCap: " ",
    updated: "更新于",
    langPrompt: "选择语言",
    kasPrice: "KAS价格",
    hashrate: "算力",
    gasFee: "Gas费用",
    latestTicker: "最新Ticker",
    customTickerTitle: "自定义 Ticker",
    customTickerMessage: "请输入您想要显示的 Ticker,用逗号或空格分隔(最多 6 个)",
    customTickerPlaceholder: "例如:KASPY,KASPER,KASJAK",
    confirm: "确认",
    cancel: "取消"
  },
  "English": {
    title: "KRC20 Market",
    price: " ",
    change: " ",
    marketCap: " ",
    updated: "Updated at",
    langPrompt: "Choose language",
    kasPrice: "KAS Price",
    hashrate: "Hashrate",
    gasFee: "Gas Fee",
    latestTicker: "Latest Ticker",
    customTickerTitle: "Customize Tickers",
    customTickerMessage: "Please enter the Tickers you want to display, separated by commas or spaces (max 6)",
    customTickerPlaceholder: "e.g., KASPY,KASPER,KASJAK",
    confirm: "Confirm",
    cancel: "Cancel"
  }
}

async function getCustomTickers() {
  if (config.runsInWidget) {
    
    try {
      let savedTickers = Keychain.get("savedTickers")
      return savedTickers ? JSON.parse(savedTickers) : ["KASPY", "KASPER", "KDAO", "KASJAK", "NACHO", "DOGK"]
    } catch (error) {
      console.log("Error reading from Keychain:", error)
      return ["KASPY", "KASPER", "KDAO", "KASJAK", "NACHO", "DOGK"]
    }
  } else {
    
    let alert = new Alert()
    alert.title = languages[lang].customTickerTitle
    alert.message = languages[lang].customTickerMessage
    
    let savedTickers
    try {
      savedTickers = Keychain.get("savedTickers")
      savedTickers = savedTickers ? JSON.parse(savedTickers) : null
    } catch (error) {
      console.log("Error reading from Keychain:", error)
      savedTickers = null
    }
    
    let defaultTickers = "KASPY,KASPER,KONAN,KASJAK,NACHO,DOGK"
    let initialValue = savedTickers ? savedTickers.join(",") : defaultTickers
    
    alert.addTextField(languages[lang].customTickerPlaceholder, initialValue)
    alert.addAction(languages[lang].confirm)
    alert.addCancelAction(languages[lang].cancel)
    
    let result = await alert.present()
    if (result === -1) {
      return savedTickers || defaultTickers.split(",")
    }
    
    let inputTickers = alert.textFieldValue(0)
      .split(/[,\s]+/)
      .filter(ticker => ticker.trim() !== "")
      .map(ticker => ticker.toUpperCase())
    inputTickers = inputTickers.slice(0, 6)
    
    try {
      Keychain.set("savedTickers", JSON.stringify(inputTickers))
    } catch (error) {
      console.log("Error saving to Keychain:", error)
    }
    
    return inputTickers
  }
}

async function chooseLanguage() {
  if (config.runsInWidget) {
    return "中文" 
  }
  let alert = new Alert()
  alert.title = " 语言 / Language "
  alert.addAction("中文")
  alert.addAction("English")
  let choice = await alert.presentAlert()
  return choice === 0 ? "中文" : "English"
}

let lang = await chooseLanguage()

let tokensToShow = await getCustomTickers()

if (args.widgetParameter) {
  let inputTokens = args.widgetParameter
    .split(/[,\s]+/)
    .filter(token => token.trim() !== "")
    .map(token => token.toUpperCase());
  tokensToShow = inputTokens.slice(0, 6);
}


const SYMBOL_WIDTH = 85; 
const CONTENT_OFFSET = 8; 
const COLUMN_SPACING = 6; 
const headerWidths = [75, 65, 65]; 
// Table headers
const headers = [
  { text: languages[lang].price, icon: "dollarsign.circle", color: COLOR_GOLD },
  { text: languages[lang].change, icon: "chart.xyaxis.line", color: COLOR_BLUE },
  { text: languages[lang].marketCap, icon: "chart.pie.fill", color: COLOR_ORANGE }
]

async function loadImage(url) {
  let req = new Request(url)
  let image = await req.loadImage()
  return image
}

const UP_TRIANGLE = "▲";
const DOWN_TRIANGLE = "▼";

class API {
  constructor() {
    this.baseUrl = "https://storage.googleapis.com/kspr-api-v1/marketplace";
    this.tokenInfoUrl = "https://api.kasplex.org/v1/krc20/token";
    this.iconBaseUrl = "https://krc20-assets.kas.fyi/icons";
    this.hashrateUrl = "https://api.kaspa.org/info/hashrate?stringOnly=false";
  }

  async fetchMarketData() {
    const currentTimestamp = Math.floor(Date.now() / 1000);
    const url = `${this.baseUrl}/marketplace.json?t=${currentTimestamp}`;
    try {
      const request = new Request(url);
      const response = await request.loadJSON();
      return response;
    } catch (error) {
      console.error(`Error fetching market data: ${error}`);
      return null;
    }
  }

  async fetchTokenInfo(tick) {
    try {
      const url = `${this.tokenInfoUrl}/${tick}`;
      const request = new Request(url);
      const response = await request.loadJSON();
      if (response.message === "successful" && response.result && response.result.length > 0) {
        return response.result[0];
      }
      console.error(`No data found for token: ${tick}`);
      return null;
    } catch (error) {
      console.error(`Error fetching token info for ${tick}: ${error}`);
      return null;
    }
  }

  async fetchImage(url) {
    try {
      const req = new Request(url);
      return await req.loadImage();
    } catch (error) {
      console.error(`Error fetching image from ${url}: ${error}`);
      return null;
    }
  }

  getTokenIconUrl(symbol) {
    return `${this.iconBaseUrl}/${symbol.toUpperCase()}.jpg`;
  }

  async fetchHashrate() {
    try {
      const request = new Request(this.hashrateUrl);
      const response = await request.loadJSON();
      return response.hashrate;
    } catch (error) {
      console.error(`Error fetching hashrate: ${error}`);
      return null;
    }
  }

  async fetchLatestTickers() {
    try {
      const url = "https://api.kasplex.org/v1/krc20/tokenlist";
      const request = new Request(url);
      const response = await request.loadJSON();
      if (response.message === "successful" && response.result) {
        return response.result
          .sort((a, b) => b.opScoreAdd - a.opScoreAdd)
          .slice(0, 4);
      }
      console.error("Failed to fetch latest tickers");
      return [];
    } catch (error) {
      console.error(`Error fetching latest tickers: ${error}`);
      return [];
    }
  }

  async getFeeEstimate() {
    try {
      const url = "https://api.kaspa.org/info/fee-estimate";
      const request = new Request(url);
      const response = await request.loadJSON();
      return response;
    } catch (error) {
      console.error(`Error fetching fee estimate: ${error}`);
      return null;
    }
  }

  calculateFee(mass, feerate) {
    const feeInSompi = mass * feerate;
    const feeInKas = feeInSompi / 100000000; // 1 KAS = 100,000,000 sompi
    return feeInKas;
  }

  async getGasFees(mass = 1000) {
    const feeEstimate = await this.getFeeEstimate();
    if (feeEstimate) {
      return {
        high: this.calculateFee(mass, feeEstimate.priorityBucket.feerate),
        normal: this.calculateFee(mass, feeEstimate.normalBuckets[0].feerate),
        low: this.calculateFee(mass, feeEstimate.lowBuckets[0].feerate)
      };
    }
    return null;
  }
}

function formatNumber(num) {
  if (num >= 1e9) {
    return (num / 1e9).toFixed(2) + 'B';
  } else if (num >= 1e6) {
    return (num / 1e6).toFixed(2) + 'M';
  } else if (num >= 1e4) {
    return (num / 1e3).toFixed(2) + 'K';
  } else {
    return num.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  }
}

function formatHashrate(hashrate) {
  const ehHashrate = hashrate / 1e6; 
  return `${ehHashrate.toFixed(2)} EH/s`;
}

async function createTable(widget, headers, data, kasData, extraData, api) {
  const table = widget.addStack();
  table.layoutHorizontally();
  table.addSpacer() 
  const contentStack = table.addStack();
  contentStack.layoutVertically();

  let titleStack = contentStack.addStack()
  titleStack.layoutHorizontally()
  titleStack.centerAlignContent()

  let leftTitleStack = titleStack.addStack()
  leftTitleStack.layoutHorizontally()
  leftTitleStack.centerAlignContent()

  let titleSymbol = leftTitleStack.addImage(SFSymbol.named("chart.line.uptrend.xyaxis").image)
  titleSymbol.imageSize = new Size(24, 24)
  titleSymbol.tintColor = COLOR_WHITE

  leftTitleStack.addSpacer(8)

  let titleText = leftTitleStack.addText(languages[lang].title)
  titleText.font = Font.boldSystemFont(20)
  titleText.textColor = COLOR_WHITE

  titleStack.addSpacer()

  let authorText = titleStack.addText(" ❤️ by @Dodo13080274")
  authorText.font = Font.systemFont(10)
  authorText.textColor = COLOR_LIGHT_BLUE
  authorText.textOpacity = 0.8

  contentStack.addSpacer(15)

  const headerRow = contentStack.addStack()
  headerRow.layoutHorizontally()
  headerRow.addSpacer(SYMBOL_WIDTH + CONTENT_OFFSET)

  headers.forEach((header, index) => {
    const headerCell = headerRow.addStack()
    headerCell.layoutHorizontally()
    headerCell.centerAlignContent()
    headerCell.size = new Size(headerWidths[index], 24)

    const icon = headerCell.addImage(SFSymbol.named(header.icon).image)
    icon.imageSize = new Size(18, 18)
    icon.tintColor = header.color  
    headerCell.addSpacer(4)

    const headerText = headerCell.addText(header.text)
    headerText.font = Font.systemFont(13)
    headerText.textColor = header.color  
    headerText.lineLimit = 1

    if (index < headers.length - 1) {
      headerRow.addSpacer(COLUMN_SPACING) 
    }
  })

  contentStack.addSpacer(8)

  for (const row of data) {
    const dataRow = contentStack.addStack();
    dataRow.layoutHorizontally();
    dataRow.centerAlignContent();

    const iconUrl = api.getTokenIconUrl(row.symbol);
    const icon = await api.fetchImage(iconUrl);
    if (icon) {
      const iconElement = dataRow.addImage(icon);
      iconElement.imageSize = new Size(22, 22);
      iconElement.cornerRadius = 11;
      dataRow.addSpacer(4);
    } else {
      const placeholderText = dataRow.addText("?");
      placeholderText.font = Font.mediumSystemFont(16);
      placeholderText.textColor = COLOR_LIGHT_BLUE;
      dataRow.addSpacer(4);
    }

    const symbolCell = dataRow.addStack();
    symbolCell.layoutHorizontally();
    symbolCell.centerAlignContent();
    symbolCell.size = new Size(SYMBOL_WIDTH - (icon ? 26 : 0), 24);
    const symbolText = symbolCell.addText(row.symbol);
    symbolText.font = Font.mediumSystemFont(13);
    symbolText.textColor = COLOR_WHITE;
    symbolText.lineLimit = 1;

    dataRow.addSpacer(CONTENT_OFFSET);

    const changeValue = row.change_24h;
    const changeText = changeValue ? `${Math.abs(changeValue).toFixed(2)}%` : 'N/A';
    const changeSymbol = changeValue > 0 ? UP_TRIANGLE : (changeValue < 0 ? DOWN_TRIANGLE : '');
    const changeColor = changeValue > 0 ? COLOR_GREEN : (changeValue < 0 ? COLOR_RED : COLOR_LIGHT_PURPLE);

    const marketCapText = row.marketCap ? `$${formatNumber(row.marketCap)}` : 'N/A';

    const values = [
      `$${row.usdPrice?.toFixed(6) || 'N/A'}`,
      changeText,
      marketCapText
    ];

    values.forEach((value, index) => {
      const cell = dataRow.addStack();
      cell.layoutHorizontally();
      cell.centerAlignContent();
      cell.size = new Size(headerWidths[index], 24);

      const text = cell.addText(value);
      text.font = Font.systemFont(12);
      
      if (index === 1) {
        text.textColor = changeColor;
      } else {
        text.textColor = COLOR_WHITE;
      }
      
      text.lineLimit = 1;

      if (index === 1 && changeSymbol) {
        cell.addSpacer(2);
        const symbolText = cell.addText(changeSymbol);
        symbolText.font = Font.mediumSystemFont(10);
        symbolText.textColor = changeColor;
      }

      if (index < values.length - 1) {
        dataRow.addSpacer(COLUMN_SPACING) 
      }
    });

    contentStack.addSpacer(6);
  }

  contentStack.addSpacer(10);

  const bottomDataStack = contentStack.addStack();
  bottomDataStack.layoutHorizontally();
  bottomDataStack.centerAlignContent();

  const leftStack = bottomDataStack.addStack();
  leftStack.layoutVertically();
  leftStack.centerAlignContent();

  const priceStack = leftStack.addStack();
  priceStack.layoutHorizontally();
  priceStack.centerAlignContent();

  const kasIconStack = priceStack.addStack();
  kasIconStack.size = new Size(16, 16);
  kasIconStack.cornerRadius = 8;
  kasIconStack.backgroundColor = new Color("#70dabf");

  const kasIconText = kasIconStack.addText("𐤊");
  kasIconText.font = Font.boldSystemFont(14);
  kasIconText.textColor = Color.white();
  kasIconText.centerAlignText();

  priceStack.addSpacer(6);

  const priceInfoStack = priceStack.addStack();
  priceInfoStack.layoutHorizontally();
  priceInfoStack.centerAlignContent();

  const priceText = priceInfoStack.addText(`$${kasData.floor_price?.toFixed(3) || 'N/A'}`);
  priceText.font = Font.mediumSystemFont(16);
  priceText.textColor = COLOR_WHITE;

  priceInfoStack.addSpacer(4);

  const changeValue = kasData.change_24h;
  const changeText = changeValue ? `${Math.abs(changeValue).toFixed(2)}%` : 'N/A';
  const changeSymbol = changeValue > 0 ? UP_TRIANGLE : (changeValue < 0 ? DOWN_TRIANGLE : '');
  const changeColor = changeValue > 0 ? COLOR_GREEN : (changeValue < 0 ? COLOR_RED : COLOR_LIGHT_PURPLE);

  const changeStack = priceInfoStack.addStack();
  changeStack.layoutHorizontally();

  const changeValueText = changeStack.addText(changeText);
  changeValueText.font = Font.systemFont(12);
  changeValueText.textColor = changeColor;

  if (changeSymbol) {
    changeStack.addSpacer(2);
    const changeSymbolText = changeStack.addText(changeSymbol);
    changeSymbolText.font = Font.mediumSystemFont(12);
    changeSymbolText.textColor = changeColor;
  }

  leftStack.addSpacer(8);

  const hashrateStack = leftStack.addStack();
  hashrateStack.layoutHorizontally();
  hashrateStack.centerAlignContent();

  const hashrateIcon = hashrateStack.addImage(SFSymbol.named("bolt.fill").image);
  hashrateIcon.imageSize = new Size(16, 16);
  hashrateIcon.tintColor = new Color("#39FF14");

  hashrateStack.addSpacer(6);

  const hashrateText = hashrateStack.addText(extraData.hashrate);
  hashrateText.font = Font.mediumSystemFont(16);
  hashrateText.textColor = COLOR_WHITE;

  bottomDataStack.addSpacer();

  const rightStack = bottomDataStack.addStack();
  rightStack.layoutVertically();
  rightStack.centerAlignContent();

  const latestTickerStack = rightStack.addStack();
  latestTickerStack.layoutHorizontally();
  latestTickerStack.centerAlignContent();

  const latestTickerIcon = latestTickerStack.addImage(SFSymbol.named("sparkles").image);
  latestTickerIcon.imageSize = new Size(16, 16);
  latestTickerIcon.tintColor = new Color("#FFD700");

  latestTickerStack.addSpacer(4);

  
  const gasIcon = latestTickerStack.addImage(SFSymbol.named("fuelpump.fill").image);
  gasIcon.imageSize = new Size(16, 16);
  gasIcon.tintColor = new Color("#FFA500");

  latestTickerStack.addSpacer(4);

  if (extraData.gasFees) {
    const gasLevels = [
      { value: extraData.gasFees.low, color: COLOR_GREEN },
      { value: extraData.gasFees.normal, color: COLOR_ORANGE },
      { value: extraData.gasFees.high, color: COLOR_RED }
    ];
  
    gasLevels.forEach((level, index) => {
      if (index > 0) latestTickerStack.addSpacer(2);
      
      let displayValue;
      if (level.value >= 0.01) {
        displayValue = level.value.toFixed(2);
      } else {
        displayValue = level.value.toExponential(0);
      }
      
      const valueText = latestTickerStack.addText(displayValue);
      valueText.font = Font.systemFont(10);
      valueText.textColor = level.color;
    });
  }
    else {
    const gasText = latestTickerStack.addText("N/A");
    gasText.font = Font.systemFont(8);
    gasText.textColor = COLOR_WHITE;
  }

  rightStack.addSpacer(4);

  const freshnessColors = [
    new Color("#FFD700"),
    new Color("#FFA500"),
    new Color("#98FB98"),
    COLOR_WHITE
  ];

  const tickerColumnsStack = rightStack.addStack();
  tickerColumnsStack.layoutHorizontally();

  const leftColumn = tickerColumnsStack.addStack();
  leftColumn.layoutVertically();
  const rightColumn = tickerColumnsStack.addStack();
  rightColumn.layoutVertically();

  extraData.latestTickers.forEach((ticker, index) => {
    const column = index % 2 === 0 ? leftColumn : rightColumn;
    const tickerStack = column.addStack();
    tickerStack.layoutHorizontally();
    tickerStack.centerAlignContent();

    const tickerText = tickerStack.addText(ticker.tick);
    tickerText.font = Font.systemFont(10);
    tickerText.textColor = freshnessColors[index];

    tickerStack.addSpacer(4);

    const timeAgo = calculateTimeAgo(ticker.mtsAdd);
    const timeText = tickerStack.addText(timeAgo);
    timeText.font = Font.systemFont(8);
    timeText.textColor = COLOR_LIGHT_PURPLE;

    column.addSpacer(4);
  });

  return table;
}
function calculateTimeAgo(timestamp) {
  const now = Date.now();
  const diff = now - timestamp;
  const minutes = Math.floor(diff / 60000);
  const hours = Math.floor(minutes / 60);
  const days = Math.floor(hours / 24);

  if (days > 0) {
    return days === 1 ? "1d ago " : `${days}d ago `;
  }
  if (hours > 0) {
    return hours === 1 ? "1h ago " : `${hours}h ago `;
  }
  if (minutes > 0) {
    return minutes === 1 ? "1m ago " : `${minutes}m ago `;
  }
  return "just now";
}

async function run() {
  const api = new API();
  

  const marketData = await api.fetchMarketData();
  if (!marketData) {
    console.error("Failed to fetch market data");
    return;
  }

  const kasData = marketData["KAS"] || { floor_price: null, change_24h: null };
  const tableData = await Promise.all(tokensToShow.map(async (symbol) => {
    const marketInfo = marketData[symbol] || { floor_price: null, change_24h: null };
    const tokenInfo = await api.fetchTokenInfo(symbol);
    let marketCap = null;
    let usdPrice = null;

    if (tokenInfo && marketInfo.floor_price && kasData.floor_price) {
      usdPrice = marketInfo.floor_price * kasData.floor_price;
      const maxSupply = Number(tokenInfo.max) / 1e8;
      marketCap = maxSupply * usdPrice;
    }

    return {
      symbol,
      ...marketInfo,
      usdPrice,
      marketCap
    };
  }));

  const hashrate = await api.fetchHashrate();
  const formattedHashrate = hashrate ? formatHashrate(hashrate) : 'N/A';

  const latestTickers = await api.fetchLatestTickers();

  const gasFees = await api.getGasFees();

  const extraData = {
    hashrate: formattedHashrate,
    latestTickers: latestTickers,
    gasFees: gasFees
  };

  const table = await createTable(widget, headers, tableData, kasData, extraData, api);

  widget.addSpacer()
  
  
  const bottomBar = widget.addStack()
  bottomBar.layoutHorizontally()
  
  const apiText = bottomBar.addText("API: Kaspa, Kasplex, KSPR")
  apiText.font = Font.systemFont(9)
  apiText.textColor = COLOR_LIGHT_BLUE
  apiText.textOpacity = 0.8
  
  bottomBar.addSpacer() 
  
  
  const updatedTimeText = bottomBar.addText(`${languages[lang].updated} ${new Date().toLocaleTimeString()}`)
  updatedTimeText.font = Font.systemFont(9)
  updatedTimeText.textColor = COLOR_LIGHT_BLUE
  updatedTimeText.textOpacity = 0.8
  updatedTimeText.rightAlignText()

  if (config.runsInWidget) {
    Script.setWidget(widget)
  } else {
    widget.presentLarge()
  }
  widget.url = "https://x.com/Dodo13080274";

  Script.complete()
}

await run();









Subscribe to 0xEb36…72AD
Receive the latest updates directly to your inbox.
Nft graphic
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.
More from 0xEb36…72AD

Skeleton

Skeleton

Skeleton