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
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")
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];
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;
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();