+ Login fires a tacklebox:// deep link that opens the wallet with the ESR
+ login request (QR and copy-paste into
+ Contracts → ESR → CONNECT AS LOGIN remain as fallbacks). Transfer
+ sends 0.0001 of the core token to yourself, pushed straight into the wallet over the
+ link channel — TackleBox raises its own window for review, and with a matching
+ pinned auto-sign whitelist rule it completes without touching the wallet.
+
+
Not logged in.
+
+
+
+
--
+
+
+
diff --git a/packages/wallet-plugin-tacklebox/examples/dapp/package.json b/packages/wallet-plugin-tacklebox/examples/dapp/package.json
new file mode 100644
index 00000000..b466b763
--- /dev/null
+++ b/packages/wallet-plugin-tacklebox/examples/dapp/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "wallet-plugin-tacklebox-example",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@wharfkit/session": "4.0.0-rc7",
+ "@wharfkit/web-renderer": "4.0.0-rc7",
+ "@wharfkit/wallet-plugin-tacklebox": "file:../.."
+ },
+ "devDependencies": {
+ "vite": "^5.4.0"
+ }
+}
diff --git a/packages/wallet-plugin-tacklebox/examples/dapp/public/tacklebox.svg b/packages/wallet-plugin-tacklebox/examples/dapp/public/tacklebox.svg
new file mode 100644
index 00000000..32e11662
--- /dev/null
+++ b/packages/wallet-plugin-tacklebox/examples/dapp/public/tacklebox.svg
@@ -0,0 +1,15 @@
+
diff --git a/packages/wallet-plugin-tacklebox/examples/dapp/src/main.ts b/packages/wallet-plugin-tacklebox/examples/dapp/src/main.ts
new file mode 100644
index 00000000..633eadf1
--- /dev/null
+++ b/packages/wallet-plugin-tacklebox/examples/dapp/src/main.ts
@@ -0,0 +1,137 @@
+import {Asset, Session, SessionKit} from '@wharfkit/session'
+import {WebRenderer} from '@wharfkit/web-renderer'
+import {WalletPluginTackleBox} from '@wharfkit/wallet-plugin-tacklebox'
+
+// Core token per supported chain, for the self-transfer demo action.
+const CORE_TOKEN: Record = {
+ // Jungle4 testnet - the recommended chain for a first run.
+ '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d': {
+ contract: 'eosio.token',
+ symbol: '4,EOS',
+ },
+ // EOS / Vaulta
+ aca376f206b8fc25a6ed44dbdc66547c36c6c33e3a119ffbeaef943642f0e906: {
+ contract: 'eosio.token',
+ symbol: '4,EOS',
+ },
+ // WAX
+ '1064487b3cd1a897ce03ae5b6a865651747e2e152090f99c1d19d44e01aea5a4': {
+ contract: 'eosio.token',
+ symbol: '8,WAX',
+ },
+ // Telos
+ '4667b205c6838ef70ff7988f6e8257e8be0e1284a2f59699054a018f743b1d11': {
+ contract: 'eosio.token',
+ symbol: '4,TLOS',
+ },
+}
+
+const sessionKit = new SessionKit({
+ appName: 'tacklebox-example',
+ chains: [
+ {
+ id: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d',
+ url: 'https://jungle4.greymass.com',
+ },
+ {
+ id: 'aca376f206b8fc25a6ed44dbdc66547c36c6c33e3a119ffbeaef943642f0e906',
+ url: 'https://eos.greymass.com',
+ },
+ {
+ id: '1064487b3cd1a897ce03ae5b6a865651747e2e152090f99c1d19d44e01aea5a4',
+ url: 'https://wax.greymass.com',
+ },
+ {
+ id: '4667b205c6838ef70ff7988f6e8257e8be0e1284a2f59699054a018f743b1d11',
+ url: 'https://telos.greymass.com',
+ },
+ ],
+ ui: new WebRenderer(),
+ walletPlugins: [new WalletPluginTackleBox()],
+})
+
+let session: Session | undefined
+
+const el = (id: string) => document.getElementById(id) as HTMLButtonElement
+const output = document.getElementById('output') as HTMLPreElement
+const status = document.getElementById('status') as HTMLDivElement
+
+function render() {
+ if (session) {
+ status.textContent = `Logged in as ${session.actor}@${session.permission} (chain ${String(
+ session.chain.id
+ ).slice(0, 8)}…)`
+ } else {
+ status.textContent = 'Not logged in.'
+ }
+ el('login').disabled = Boolean(session)
+ el('transfer').disabled = !session
+ el('logout').disabled = !session
+}
+
+function show(value: unknown) {
+ output.textContent =
+ value instanceof Error ? `Error: ${value.message}` : JSON.stringify(value, null, 2)
+}
+
+el('login').addEventListener('click', async () => {
+ try {
+ const result = await sessionKit.login()
+ session = result.session
+ show({connected: String(result.response.permissionLevel)})
+ } catch (error) {
+ show(error)
+ }
+ render()
+})
+
+el('logout').addEventListener('click', async () => {
+ if (session) {
+ await sessionKit.logout(session)
+ session = undefined
+ show('Logged out.')
+ }
+ render()
+})
+
+el('transfer').addEventListener('click', async () => {
+ if (!session) return
+ const core = CORE_TOKEN[String(session.chain.id)]
+ if (!core) {
+ show(new Error('No core token configured for this chain'))
+ return
+ }
+ try {
+ const result = await session.transact({
+ action: {
+ account: core.contract,
+ name: 'transfer',
+ authorization: [session.permissionLevel],
+ data: {
+ from: session.actor,
+ to: session.actor,
+ quantity: Asset.fromUnits(1, core.symbol),
+ memo: 'wallet-plugin-tacklebox example',
+ },
+ },
+ })
+ show({
+ transaction_id: result.resolved?.transaction.id
+ ? String(result.resolved.transaction.id)
+ : undefined,
+ signatures: result.signatures.map(String),
+ })
+ } catch (error) {
+ show(error)
+ }
+})
+
+sessionKit.restore().then((restored) => {
+ if (restored) {
+ session = restored
+ show('Session restored.')
+ }
+ render()
+})
+
+render()
diff --git a/packages/wallet-plugin-tacklebox/package.json b/packages/wallet-plugin-tacklebox/package.json
new file mode 100644
index 00000000..2dcb8c0a
--- /dev/null
+++ b/packages/wallet-plugin-tacklebox/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@wharfkit/wallet-plugin-tacklebox",
+ "description": "A TackleBox plugin for use with @wharfkit/session.",
+ "version": "4.0.0",
+ "homepage": "https://wharfkit.com/plugins/wallet-plugin-tacklebox",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "main": "lib/wallet-plugin-tacklebox.js",
+ "module": "lib/wallet-plugin-tacklebox.m.js",
+ "types": "lib/wallet-plugin-tacklebox.d.ts",
+ "sideEffects": false,
+ "files": [
+ "lib/*",
+ "src/*"
+ ],
+ "scripts": {},
+ "dependencies": {
+ "@greymass/buoy": "catalog:",
+ "@wharfkit/protocol-esr": "workspace:*",
+ "@wharfkit/session": "workspace:*",
+ "isomorphic-ws": "catalog:"
+ },
+ "devDependencies": {
+ "@wharfkit/antelope": "workspace:*",
+ "@wharfkit/mock-data": "workspace:*",
+ "pako": "catalog:"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/wharfkit/js.git",
+ "directory": "packages/wallet-plugin-tacklebox"
+ }
+}
diff --git a/packages/wallet-plugin-tacklebox/src/index.ts b/packages/wallet-plugin-tacklebox/src/index.ts
new file mode 100644
index 00000000..b2b26284
--- /dev/null
+++ b/packages/wallet-plugin-tacklebox/src/index.ts
@@ -0,0 +1,491 @@
+import {
+ AbstractWalletPlugin,
+ CallbackPayload,
+ Cancelable,
+ Checksum256,
+ LoginContext,
+ PermissionLevel,
+ PrivateKey,
+ PromptElement,
+ PromptResponse,
+ PublicKey,
+ ResolvedSigningRequest,
+ Serializer,
+ SigningRequest,
+ TransactContext,
+ WalletPluginConfig,
+ WalletPluginLoginResponse,
+ WalletPluginMetadata,
+ WalletPluginSignResponse,
+} from '@wharfkit/session'
+import {
+ createIdentityRequest,
+ extractSignaturesFromCallback,
+ isCallback,
+ isKnownMobile,
+ LinkInfo,
+ sealMessage,
+ setTransactionCallback,
+ verifyLoginCallbackResponse,
+ waitForCallback,
+} from '@wharfkit/protocol-esr'
+import {send} from '@greymass/buoy'
+import WebSocket from 'isomorphic-ws'
+
+import defaultTranslations from './translations'
+import {tackleboxLogo} from './logo'
+
+/** Options for the TackleBox wallet plugin. */
+export interface WalletPluginTackleBoxOptions {
+ /**
+ * Buoy callback-forwarder service used for this dapp's callbacks.
+ *
+ * TackleBox's own listening channel is announced by the wallet at login
+ * (`link_ch`) and does not depend on this value.
+ */
+ buoyUrl?: string
+ /** WebSocket constructor override forwarded to buoy callback handling. */
+ buoyWs?: WebSocket
+ /**
+ * Skip firing the `tacklebox:` deep link that opens the wallet app
+ * automatically on login and manual signing. The prompt always keeps the
+ * launch link, QR code and copy fallbacks.
+ */
+ disableAutoLaunch?: boolean
+ /** @internal */
+ send?: typeof send
+ /** @internal */
+ waitForCallback?: typeof waitForCallback
+}
+
+/** Default buoy service, shared with the rest of the anchor-link ecosystem. */
+export const DEFAULT_BUOY_URL = 'https://cb.anchor.link'
+
+/**
+ * A @wharfkit/session WalletPlugin for TackleBox, the native wallet and block
+ * explorer for Antelope blockchains (https://github.com/doyen-games/tacklebox).
+ *
+ * TackleBox speaks the anchor-link protocol. Logging in fires a `tacklebox:`
+ * deep link that opens the wallet with the ESR identity request (with QR code
+ * and copy-paste fallbacks in the prompt); TackleBox answers over a buoy
+ * callback and opens a sealed push channel. Transactions are then pushed
+ * straight into the wallet, which raises its own window for review - every
+ * request still passes TackleBox's whitelist guard and signing review.
+ *
+ * The plugin is renderer-agnostic: it speaks only the abstract UserInterface
+ * contract, so any session UI (web-renderer, web-ui, or custom) renders it.
+ */
+export class WalletPluginTackleBox extends AbstractWalletPlugin {
+ buoyUrl: string
+ buoyWs: WebSocket | undefined
+ autoLaunch: boolean
+ /** @internal */
+ send: typeof send
+ /** @internal */
+ waitForCallback: typeof waitForCallback
+
+ /**
+ * The unique identifier for the wallet plugin.
+ */
+ get id(): string {
+ return 'tacklebox'
+ }
+
+ /**
+ * The translations for this plugin.
+ */
+ translations = defaultTranslations
+
+ constructor(options?: WalletPluginTackleBoxOptions) {
+ super()
+ this.buoyUrl = options?.buoyUrl || DEFAULT_BUOY_URL
+ this.buoyWs = options?.buoyWs || WebSocket
+ this.autoLaunch = !options?.disableAutoLaunch
+ this.send = options?.send || send
+ this.waitForCallback = options?.waitForCallback || waitForCallback
+ }
+
+ /**
+ * The logic configuration for the wallet plugin.
+ */
+ readonly config: WalletPluginConfig = {
+ // TackleBox resolves the chain from the request (or asks the user),
+ // so the UI never needs a chain selector.
+ requiresChainSelect: false,
+ // The user picks the account inside TackleBox.
+ requiresPermissionSelect: false,
+ }
+
+ /**
+ * The metadata for the wallet plugin to be displayed in the user interface.
+ */
+ readonly metadata: WalletPluginMetadata = WalletPluginMetadata.from({
+ name: 'TackleBox',
+ description: 'A native wallet and block explorer for Antelope blockchains.',
+ logo: tackleboxLogo,
+ homepage: 'https://github.com/doyen-games/tacklebox',
+ download: 'https://github.com/doyen-games/tacklebox/releases',
+ })
+
+ login(context: LoginContext): Promise {
+ return this.handleLogin(context)
+ }
+
+ private async handleLogin(context: LoginContext): Promise {
+ if (!context.ui) {
+ throw new Error('The TackleBox wallet plugin requires a UI to be present.')
+ }
+
+ const t = context.ui.getTranslate(this.id)
+
+ const {callback, request, sameDeviceRequest, requestKey, privateKey} =
+ await createIdentityRequest(context, this.buoyUrl)
+
+ const encodedRequest = request.encode(true, true, 'esr:')
+ const launchUrl = tackleboxDeepLink(sameDeviceRequest)
+
+ const prompt = (copied: boolean) => {
+ const elements: PromptElement[] = [
+ {
+ type: 'link',
+ label: t('login.launch', {default: 'Launch TackleBox'}),
+ data: {
+ href: launchUrl,
+ label: t('login.launch', {default: 'Launch TackleBox'}),
+ variant: 'primary',
+ },
+ },
+ {
+ type: 'button',
+ label: t('login.copy', {default: 'Copy login request'}),
+ data: {
+ label: t('login.copy', {default: 'Copy login request'}),
+ onClick: () => {
+ copyToClipboard(encodedRequest).then(
+ (ok) => ok && !copied && prompt(true)
+ )
+ },
+ },
+ },
+ ]
+
+ // The QR code only helps when this device is not the one
+ // running the wallet - hide it on known-mobile dapp devices.
+ if (!onMobileDevice()) {
+ elements.unshift({
+ type: 'qr',
+ data: encodedRequest,
+ })
+ }
+
+ const promptPromise = context.ui!.prompt({
+ title: t('login.title', {default: 'Connect with TackleBox'}),
+ body: copied
+ ? t('login.copied', {
+ default:
+ 'Request copied. In TackleBox, open Contracts → ESR → CONNECT AS LOGIN and paste it to continue.',
+ })
+ : t('login.body', {
+ default:
+ 'TackleBox should open with this login request - approve the connection there. If nothing opens, launch it with the button, copy the request into TackleBox (Contracts → ESR → CONNECT AS LOGIN), or scan the QR code.',
+ }),
+ elements,
+ })
+ // The modal closing is handled by the session kit; swallow the rejection.
+ promptPromise.catch(() => undefined)
+ }
+
+ prompt(false)
+
+ // Open the wallet directly with the request, anchor-style.
+ if (this.autoLaunch) {
+ openDeepLink(launchUrl)
+ }
+
+ const callbackResponse: CallbackPayload = await this.waitForCallback(
+ callback,
+ this.buoyWs,
+ t
+ )
+
+ verifyLoginCallbackResponse(callbackResponse, context)
+
+ if (!callbackResponse.cid || !callbackResponse.sa || !callbackResponse.sp) {
+ throw new Error(
+ t('error.invalid_response', {
+ default:
+ 'Invalid response from TackleBox, it must contain the cid, sa and sp fields.',
+ })
+ )
+ }
+
+ // TackleBox always opens a push channel on login; store it so
+ // transact requests can be pushed straight into the wallet.
+ if (callbackResponse.link_ch && callbackResponse.link_key && callbackResponse.link_name) {
+ this.data.requestKey = requestKey
+ this.data.privateKey = privateKey
+ this.data.signerKey = PublicKey.from(callbackResponse.link_key)
+ this.data.channelUrl = callbackResponse.link_ch
+ this.data.channelName = callbackResponse.link_name
+ }
+
+ const resolvedResponse = await ResolvedSigningRequest.fromPayload(
+ callbackResponse,
+ context.esrOptions
+ )
+
+ return {
+ chain: Checksum256.from(callbackResponse.cid),
+ permissionLevel: PermissionLevel.from({
+ actor: callbackResponse.sa,
+ permission: callbackResponse.sp,
+ }),
+ identityProof: resolvedResponse.getIdentityProof(callbackResponse.sig),
+ }
+ }
+
+ sign(
+ resolved: ResolvedSigningRequest,
+ context: TransactContext
+ ): Promise {
+ return this.handleSign(resolved, context)
+ }
+
+ private async handleSign(
+ resolved: ResolvedSigningRequest,
+ context: TransactContext
+ ): Promise {
+ if (!context.ui) {
+ throw new Error('The TackleBox wallet plugin requires a UI to be present.')
+ }
+
+ const t = context.ui.getTranslate(this.id)
+
+ const expiration = resolved.transaction.expiration.toDate()
+ const expiresIn = Math.max(0, expiration.getTime() - Date.now())
+
+ // Re-create the request through the session so the wallet resolves it
+ // against fresh TAPOS values, and tag it with its expiration.
+ const modifiedRequest = await context.createRequest({transaction: resolved.transaction})
+ modifiedRequest.setInfoKey('link', LinkInfo.from({expiration}))
+
+ const callback = setTransactionCallback(modifiedRequest, this.buoyUrl)
+ const encodedRequest = modifiedRequest.encode(true, true, 'esr:')
+ const launchUrl = tackleboxDeepLink(modifiedRequest)
+
+ // Track every prompt shown for this signature so the first one the
+ // user dismisses cancels the flow, whichever variant is on screen.
+ const prompts: Cancelable[] = []
+ let onPromptSettled: (p: Cancelable) => void = () => undefined
+ const promptSettled = new Promise((resolve, reject) => {
+ onPromptSettled = (p) => {
+ prompts.push(p)
+ p.then(resolve, reject)
+ }
+ })
+
+ const countdown: PromptElement = {
+ type: 'countdown',
+ data: {
+ label: t('transact.await', {default: 'Waiting for TackleBox'}),
+ end: expiration.toISOString(),
+ },
+ }
+
+ const launchElement: PromptElement = {
+ type: 'link',
+ label: t('transact.launch', {default: 'Open TackleBox'}),
+ data: {
+ href: launchUrl,
+ label: t('transact.launch', {default: 'Open TackleBox'}),
+ },
+ }
+
+ const promptManual = (copied: boolean) => {
+ const elements: PromptElement[] = [
+ launchElement,
+ {
+ type: 'button',
+ label: t('transact.copy', {default: 'Copy signing request'}),
+ data: {
+ label: t('transact.copy', {default: 'Copy signing request'}),
+ onClick: () => {
+ copyToClipboard(encodedRequest).then(
+ (ok) => ok && !copied && promptManual(true)
+ )
+ },
+ },
+ },
+ countdown,
+ ]
+ if (!onMobileDevice()) {
+ elements.unshift({
+ type: 'qr',
+ data: encodedRequest,
+ })
+ }
+ onPromptSettled(
+ context.ui!.prompt({
+ title: t('transact.title_manual', {default: 'Sign with TackleBox'}),
+ body: copied
+ ? t('transact.copied', {
+ default:
+ 'Request copied. In TackleBox, open Contracts → ESR and paste it to continue.',
+ })
+ : t('transact.body_manual', {
+ default:
+ 'TackleBox should open with this signing request. If nothing opens, launch it with the button, copy the request into TackleBox (Contracts → ESR), or scan the QR code.',
+ }),
+ elements,
+ })
+ )
+ }
+
+ if (this.data.channelUrl) {
+ onPromptSettled(
+ context.ui.prompt({
+ title: t('transact.title', {default: 'Sign with TackleBox'}),
+ body: t('transact.body', {
+ channelName: this.data.channelName,
+ default: `Review and approve this transaction in TackleBox ("${this.data.channelName}").`,
+ }),
+ elements: [
+ countdown,
+ launchElement,
+ {
+ type: 'button',
+ label: t('transact.manual', {default: 'Sign manually instead'}),
+ data: {
+ label: t('transact.manual', {
+ default: 'Sign manually instead',
+ }),
+ onClick: () => promptManual(false),
+ },
+ },
+ ],
+ })
+ )
+ } else {
+ promptManual(false)
+ // No push channel: open the wallet directly with the request.
+ if (this.autoLaunch) {
+ openDeepLink(launchUrl)
+ }
+ }
+
+ // Timeouts above 2^31-1ms fire immediately; clamp far-future expiries.
+ const timer = setTimeout(
+ () => {
+ prompts.forEach((p) =>
+ p.cancel(
+ t('error.expired', {default: 'The request expired, please try again.'})
+ )
+ )
+ },
+ Math.min(expiresIn, 0x7fffffff)
+ )
+
+ const callbackPromise = this.waitForCallback(callback, this.buoyWs, t)
+
+ if (this.data.channelUrl) {
+ // Seal the request to the wallet's session key and push it
+ // into TackleBox over its buoy channel; the wallet raises its
+ // own window when the request arrives.
+ const service = new URL(this.data.channelUrl).origin
+ const channel = new URL(this.data.channelUrl).pathname.substring(1)
+ const sealedMessage = await sealMessage(
+ encodedRequest,
+ PrivateKey.from(this.data.privateKey),
+ PublicKey.from(this.data.signerKey)
+ )
+ this.send(Serializer.encode({object: sealedMessage}).array, {service, channel})
+ }
+
+ const callbackResponse = await Promise.race([callbackPromise, promptSettled]).finally(
+ () => {
+ clearTimeout(timer)
+ prompts.forEach((p) => p.cancel())
+ }
+ )
+
+ if (
+ isCallback(callbackResponse) &&
+ extractSignaturesFromCallback(callbackResponse).length
+ ) {
+ const resolvedRequest = await ResolvedSigningRequest.fromPayload(
+ callbackResponse,
+ context.esrOptions
+ )
+ return {
+ signatures: extractSignaturesFromCallback(callbackResponse),
+ resolved: resolvedRequest,
+ }
+ }
+
+ throw new Error(t('error.not_completed', {default: 'The request was not completed.'}))
+ }
+}
+
+/**
+ * The `tacklebox:` deep link that opens the wallet app with a signing request.
+ *
+ * TackleBox registers the `tacklebox:` scheme (and claims `esr:` only when no
+ * other wallet has it), so plugin-fired links use its own scheme - on machines
+ * where Anchor owns `esr:`, a plain esr link would open the wrong wallet. The
+ * payload is the request body without the scheme, which the wallet turns back
+ * into an `esr://` URI.
+ */
+export function tackleboxDeepLink(request: SigningRequest): string {
+ const encoded = request.encode(true, false, 'esr:')
+ return `tacklebox://request/${encoded.slice('esr:'.length)}`
+}
+
+function openDeepLink(url: string) {
+ try {
+ if (typeof window !== 'undefined' && window.location) {
+ window.location.href = url
+ }
+ } catch {
+ // Leaving the prompt's launch link as the way in.
+ }
+}
+
+/**
+ * Whether the dapp is running on a known mobile device (where a QR code is of
+ * no use). protocol-esr's isKnownMobile assumes a browser environment, so
+ * guard it for SSR and node.
+ */
+function onMobileDevice(): boolean {
+ return typeof navigator !== 'undefined' && isKnownMobile()
+}
+
+/**
+ * Best-effort clipboard write; resolves true when the text was copied.
+ */
+export async function copyToClipboard(text: string): Promise {
+ try {
+ if (typeof navigator !== 'undefined' && navigator.clipboard) {
+ await navigator.clipboard.writeText(text)
+ return true
+ }
+ } catch {
+ // Permission denied or insecure context; try the legacy path below.
+ }
+ try {
+ if (typeof document !== 'undefined' && document.body) {
+ const element = document.createElement('textarea')
+ element.value = text
+ element.setAttribute('readonly', '')
+ element.style.position = 'absolute'
+ element.style.left = '-9999px'
+ document.body.appendChild(element)
+ element.select()
+ const copied = document.execCommand('copy')
+ document.body.removeChild(element)
+ return copied
+ }
+ } catch {
+ // Nothing else to fall back to.
+ }
+ return false
+}
diff --git a/packages/wallet-plugin-tacklebox/src/logo.ts b/packages/wallet-plugin-tacklebox/src/logo.ts
new file mode 100644
index 00000000..b2fe1e6b
--- /dev/null
+++ b/packages/wallet-plugin-tacklebox/src/logo.ts
@@ -0,0 +1,15 @@
+import {Logo} from '@wharfkit/session'
+
+/**
+ * The official TackleBox mark (assets/tacklebox.svg): an angular navy
+ * tacklebox with a teal lid, cyan circuit accents, twin latches and a fishing
+ * hook emblem. The navy fills carry it on light interfaces and the cyan
+ * light-lines carry it on dark ones, so both variants use the same art.
+ */
+const icon =
+ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiBmaWxsPSJub25lIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzYyI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+VGFja2xlYm94PC90aXRsZT4KICA8ZGVzYyBpZD0iZGVzYyI+QW4gYW5ndWxhciBuYXZ5IHRhY2tsZWJveCB3aXRoIGEgdGVhbCBsaWQsIGN5YW4gY2lyY3VpdCBhY2NlbnRzLCB0d2luIGxhdGNoZXMsIGFuZCBhIGZpc2hpbmcgaG9vayBlbWJsZW0uPC9kZXNjPgogIDxnIHN0cm9rZS1saW5lam9pbj0icm91bmQiPgogICAgPHBhdGggZmlsbD0iIzA3MTgyRSIgc3Ryb2tlPSIjMDcxODJFIiBzdHJva2Utd2lkdGg9IjEyIiBkPSJNMTAwIDczVjQzbDEwLTEwaDM2bDEwIDEwdjMwaDUybDIzIDI5djk0bC0xNyAxN0g0MmwtMTctMTd2LTk0bDIzLTI5eiIvPgogICAgPHBhdGggc3Ryb2tlPSIjNDJGNUYyIiBzdHJva2Utd2lkdGg9IjgiIGQ9Ik05OSA3MlY0NWwxMC0xMGgzOGwxMCAxMHYyNyIvPgogICAgPHBhdGggZmlsbD0iIzBCNjk3NyIgc3Ryb2tlPSIjNDJGNUYyIiBzdHJva2Utd2lkdGg9IjciIGQ9Ik00OCA3NWgxNjBsMjEgMjh2MjFIMjd2LTIxeiIvPgogICAgPHBhdGggZmlsbD0iIzA3MTgyRSIgc3Ryb2tlPSIjNDJGNUYyIiBzdHJva2Utd2lkdGg9IjciIGQ9Ik0yNyAxMjJoMjAydjczbC0xNyAxN0g0NGwtMTctMTd6Ii8+CiAgICA8cGF0aCBzdHJva2U9IiM0MkY1RjIiIHN0cm9rZS13aWR0aD0iNSIgZD0iTTQ4IDk5aDQ5bTYyIDBoNDkiLz4KICAgIDxwYXRoIGZpbGw9IiM0MkY1RjIiIHN0cm9rZT0iIzA3MTgyRSIgc3Ryb2tlLXdpZHRoPSI3IiBkPSJNNjIgMTEyaDIydjM0SDYyem0xMTAgMGgyMnYzNGgtMjJ6Ii8+CiAgICA8cGF0aCBzdHJva2U9IiMwQjY5NzciIHN0cm9rZS13aWR0aD0iNiIgZD0iTTQ2IDE1NnYyOGwxMCAxMGgzN203MCAwaDM3bDEwLTEwdi0yOCIvPgogIDwvZz4KICA8cGF0aCBzdHJva2U9IiM0MkY1RjIiIHN0cm9rZS13aWR0aD0iOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBkPSJNMTM5IDE1MXYyNGExNyAxNyAwIDAgMS0zNCAwdi02bDEwIDciLz4KICA8Y2lyY2xlIGN4PSIxMzkiIGN5PSIxNDUiIHI9IjciIGZpbGw9IiMwNzE4MkUiIHN0cm9rZT0iIzQyRjVGMiIgc3Ryb2tlLXdpZHRoPSI1Ii8+Cjwvc3ZnPgo='
+
+export const tackleboxLogo = Logo.from({
+ light: icon,
+ dark: icon,
+})
diff --git a/packages/wallet-plugin-tacklebox/src/translations/en.json b/packages/wallet-plugin-tacklebox/src/translations/en.json
new file mode 100644
index 00000000..1dd87c75
--- /dev/null
+++ b/packages/wallet-plugin-tacklebox/src/translations/en.json
@@ -0,0 +1,26 @@
+{
+ "login": {
+ "title": "Connect with TackleBox",
+ "body": "TackleBox should open with this login request - approve the connection there. If nothing opens, launch it with the button, copy the request into TackleBox (Contracts → ESR → CONNECT AS LOGIN), or scan the QR code.",
+ "copied": "Request copied. In TackleBox, open Contracts → ESR → CONNECT AS LOGIN and paste it to continue.",
+ "copy": "Copy login request",
+ "launch": "Launch TackleBox"
+ },
+ "transact": {
+ "title": "Sign with TackleBox",
+ "title_manual": "Sign with TackleBox",
+ "body": "Review and approve this transaction in TackleBox (\"{{channelName}}\").",
+ "body_manual": "TackleBox should open with this signing request. If nothing opens, launch it with the button, copy the request into TackleBox (Contracts → ESR), or scan the QR code.",
+ "copied": "Request copied. In TackleBox, open Contracts → ESR and paste it to continue.",
+ "copy": "Copy signing request",
+ "manual": "Sign manually instead",
+ "launch": "Open TackleBox",
+ "await": "Waiting for TackleBox"
+ },
+ "error": {
+ "expired": "The request expired, please try again.",
+ "invalid_response": "Invalid response from TackleBox, it must contain the cid, sa and sp fields.",
+ "not_completed": "The request was not completed.",
+ "cancelled": "The request was declined in TackleBox."
+ }
+}
diff --git a/packages/wallet-plugin-tacklebox/src/translations/index.ts b/packages/wallet-plugin-tacklebox/src/translations/index.ts
new file mode 100644
index 00000000..54df1211
--- /dev/null
+++ b/packages/wallet-plugin-tacklebox/src/translations/index.ts
@@ -0,0 +1,5 @@
+import en from './en.json'
+
+export default {
+ en,
+}
diff --git a/packages/wallet-plugin-tacklebox/test/data/a041de03f2a7ee6c133465c8c6b2b286704a5d8f.json b/packages/wallet-plugin-tacklebox/test/data/a041de03f2a7ee6c133465c8c6b2b286704a5d8f.json
new file mode 100644
index 00000000..9ca01bae
--- /dev/null
+++ b/packages/wallet-plugin-tacklebox/test/data/a041de03f2a7ee6c133465c8c6b2b286704a5d8f.json
@@ -0,0 +1,33 @@
+{
+ "request": {
+ "path": "https://jungle4.greymass.com/v1/chain/get_info",
+ "params": {
+ "method": "GET",
+ "headers": {}
+ }
+ },
+ "status": 200,
+ "json": {
+ "server_version": "13356212",
+ "chain_id": "73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d",
+ "head_block_num": 284535949,
+ "last_irreversible_block_num": 284535947,
+ "last_irreversible_block_id": "10f5ac8bd27f37626e13d55497f27512fee8b9477c1d7028bfb43478c060b8aa",
+ "head_block_id": "10f5ac8d9738a5d3fdffbff2c5d39aaf4627bb66afbc09391b38fef6f098155d",
+ "head_block_time": "2026-09-01T05:08:41.500",
+ "head_block_producer": "batinthedark",
+ "virtual_block_cpu_limit": 200000,
+ "virtual_block_net_limit": 1048576000,
+ "block_cpu_limit": 200000,
+ "block_net_limit": 1048576,
+ "server_version_string": "v1.1.5",
+ "fork_db_head_block_num": 284535949,
+ "fork_db_head_block_id": "10f5ac8d9738a5d3fdffbff2c5d39aaf4627bb66afbc09391b38fef6f098155d",
+ "server_full_version_string": "v1.1.5-13356212245054c35b1e1e7e20a9bd43ced48c4d",
+ "total_cpu_weight": "120573521296917",
+ "total_net_weight": "117540230729142",
+ "earliest_available_block_num": 284360852,
+ "last_irreversible_block_time": "2026-09-01T05:08:40.500"
+ },
+ "text": "{\"server_version\":\"13356212\",\"chain_id\":\"73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d\",\"head_block_num\":284535949,\"last_irreversible_block_num\":284535947,\"last_irreversible_block_id\":\"10f5ac8bd27f37626e13d55497f27512fee8b9477c1d7028bfb43478c060b8aa\",\"head_block_id\":\"10f5ac8d9738a5d3fdffbff2c5d39aaf4627bb66afbc09391b38fef6f098155d\",\"head_block_time\":\"2026-09-01T05:08:41.500\",\"head_block_producer\":\"batinthedark\",\"virtual_block_cpu_limit\":200000,\"virtual_block_net_limit\":1048576000,\"block_cpu_limit\":200000,\"block_net_limit\":1048576,\"server_version_string\":\"v1.1.5\",\"fork_db_head_block_num\":284535949,\"fork_db_head_block_id\":\"10f5ac8d9738a5d3fdffbff2c5d39aaf4627bb66afbc09391b38fef6f098155d\",\"server_full_version_string\":\"v1.1.5-13356212245054c35b1e1e7e20a9bd43ced48c4d\",\"total_cpu_weight\":\"120573521296917\",\"total_net_weight\":\"117540230729142\",\"earliest_available_block_num\":284360852,\"last_irreversible_block_time\":\"2026-09-01T05:08:40.500\"}"
+}
\ No newline at end of file
diff --git a/packages/wallet-plugin-tacklebox/test/data/ed314e8a151304a30043198b7b9e9df34a14aaeb.json b/packages/wallet-plugin-tacklebox/test/data/ed314e8a151304a30043198b7b9e9df34a14aaeb.json
new file mode 100644
index 00000000..69cd7cd9
--- /dev/null
+++ b/packages/wallet-plugin-tacklebox/test/data/ed314e8a151304a30043198b7b9e9df34a14aaeb.json
@@ -0,0 +1,18 @@
+{
+ "request": {
+ "path": "https://jungle4.greymass.com/v1/chain/get_raw_abi",
+ "params": {
+ "method": "POST",
+ "body": "{\"account_name\":\"eosio.token\"}",
+ "headers": {}
+ }
+ },
+ "status": 200,
+ "json": {
+ "account_name": "eosio.token",
+ "code_hash": "0a16e1dac533c4558698c8754f41219839ba2a2b75e517e65ea2537f76681f49",
+ "abi_hash": "4ddeabf8085f6a6045ec75a100d93b21f6979668d2f7ade18812a5698c6613ce",
+ "abi": "DmVvc2lvOjphYmkvMS4yAAoHYWNjb3VudAABB2JhbGFuY2UFYXNzZXQFY2xvc2UAAgVvd25lcgRuYW1lBnN5bWJvbAZzeW1ib2wGY3JlYXRlAAIGaXNzdWVyBG5hbWUObWF4aW11bV9zdXBwbHkFYXNzZXQOY3VycmVuY3lfc3RhdHMAAwZzdXBwbHkFYXNzZXQKbWF4X3N1cHBseQVhc3NldAZpc3N1ZXIEbmFtZQVpc3N1ZQADAnRvBG5hbWUIcXVhbnRpdHkFYXNzZXQEbWVtbwZzdHJpbmcKaXNzdWVmaXhlZAADAnRvBG5hbWUGc3VwcGx5BWFzc2V0BG1lbW8Gc3RyaW5nBG9wZW4AAwVvd25lcgRuYW1lBnN5bWJvbAZzeW1ib2wJcmFtX3BheWVyBG5hbWUGcmV0aXJlAAIIcXVhbnRpdHkFYXNzZXQEbWVtbwZzdHJpbmcMc2V0bWF4c3VwcGx5AAIGaXNzdWVyBG5hbWUObWF4aW11bV9zdXBwbHkFYXNzZXQIdHJhbnNmZXIABARmcm9tBG5hbWUCdG8EbmFtZQhxdWFudGl0eQVhc3NldARtZW1vBnN0cmluZwgAAAAAAIVpRAVjbG9zZfkDLS0tCnNwZWNfdmVyc2lvbjogIjAuMi4wIgp0aXRsZTogQ2xvc2UgVG9rZW4gQmFsYW5jZQpzdW1tYXJ5OiAnQ2xvc2Uge3tub3dyYXAgb3duZXJ9feKAmXMgemVybyBxdWFudGl0eSBiYWxhbmNlJwppY29uOiBodHRwczovL3Jhdy5naXRodWJ1c2VyY29udGVudC5jb20vZW9zbmV0d29ya2ZvdW5kYXRpb24vZW9zLXN5c3RlbS1jb250cmFjdHMvbWFpbi9jb250cmFjdHMvaWNvbnMvdG9rZW4ucG5nIzIwN2ZmNjhiMDQwNmVhYTU2NjE4YjA4YmRhODFkNmEwOTU0NTQzZjM2YWRjMzI4YWIzMDY1ZjMxYTVjNWQ2NTQKLS0tCgp7e293bmVyfX0gYWdyZWVzIHRvIGNsb3NlIHRoZWlyIHplcm8gcXVhbnRpdHkgYmFsYW5jZSBmb3IgdGhlIHt7c3ltYm9sX3RvX3N5bWJvbF9jb2RlIHN5bWJvbH19IHRva2VuLgoKUkFNIHdpbGwgYmUgcmVmdW5kZWQgdG8gdGhlIFJBTSBwYXllciBvZiB0aGUge3tzeW1ib2xfdG9fc3ltYm9sX2NvZGUgc3ltYm9sfX0gdG9rZW4gYmFsYW5jZSBmb3Ige3tvd25lcn19LgAAAACobNRFBmNyZWF0ZZoFLS0tCnNwZWNfdmVyc2lvbjogIjAuMi4wIgp0aXRsZTogQ3JlYXRlIE5ldyBUb2tlbgpzdW1tYXJ5OiAnQ3JlYXRlIGEgbmV3IHRva2VuJwppY29uOiBodHRwczovL3Jhdy5naXRodWJ1c2VyY29udGVudC5jb20vZW9zbmV0d29ya2ZvdW5kYXRpb24vZW9zLXN5c3RlbS1jb250cmFjdHMvbWFpbi9jb250cmFjdHMvaWNvbnMvdG9rZW4ucG5nIzIwN2ZmNjhiMDQwNmVhYTU2NjE4YjA4YmRhODFkNmEwOTU0NTQzZjM2YWRjMzI4YWIzMDY1ZjMxYTVjNWQ2NTQKLS0tCgp7eyRhY3Rpb24uYWNjb3VudH19IGFncmVlcyB0byBjcmVhdGUgYSBuZXcgdG9rZW4gd2l0aCBzeW1ib2wge3thc3NldF90b19zeW1ib2xfY29kZSBtYXhpbXVtX3N1cHBseX19IHRvIGJlIG1hbmFnZWQgYnkge3tpc3N1ZXJ9fS4KClRoaXMgYWN0aW9uIHdpbGwgbm90IHJlc3VsdCBhbnkgYW55IHRva2VucyBiZWluZyBpc3N1ZWQgaW50byBjaXJjdWxhdGlvbi4KCnt7aXNzdWVyfX0gd2lsbCBiZSBhbGxvd2VkIHRvIGlzc3VlIHRva2VucyBpbnRvIGNpcmN1bGF0aW9uLCB1cCB0byBhIG1heGltdW0gc3VwcGx5IG9mIHt7bWF4aW11bV9zdXBwbHl9fS4KClJBTSB3aWxsIGRlZHVjdGVkIGZyb20ge3skYWN0aW9uLmFjY291bnR9feKAmXMgcmVzb3VyY2VzIHRvIGNyZWF0ZSB0aGUgbmVjZXNzYXJ5IHJlY29yZHMuAAAAAAClMXYFaXNzdWXuBy0tLQpzcGVjX3ZlcnNpb246ICIwLjIuMCIKdGl0bGU6IElzc3VlIFRva2VucyBpbnRvIENpcmN1bGF0aW9uCnN1bW1hcnk6ICdJc3N1ZSB7e25vd3JhcCBxdWFudGl0eX19IGludG8gY2lyY3VsYXRpb24gYW5kIHRyYW5zZmVyIGludG8ge3tub3dyYXAgdG99feKAmXMgYWNjb3VudCcKaWNvbjogaHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2Vvc25ldHdvcmtmb3VuZGF0aW9uL2Vvcy1zeXN0ZW0tY29udHJhY3RzL21haW4vY29udHJhY3RzL2ljb25zL3Rva2VuLnBuZyMyMDdmZjY4YjA0MDZlYWE1NjYxOGIwOGJkYTgxZDZhMDk1NDU0M2YzNmFkYzMyOGFiMzA2NWYzMWE1YzVkNjU0Ci0tLQoKVGhlIHRva2VuIG1hbmFnZXIgYWdyZWVzIHRvIGlzc3VlIHt7cXVhbnRpdHl9fSBpbnRvIGNpcmN1bGF0aW9uLCBhbmQgdHJhbnNmZXIgaXQgaW50byB7e3RvfX3igJlzIGFjY291bnQuCgp7eyNpZiBtZW1vfX1UaGVyZSBpcyBhIG1lbW8gYXR0YWNoZWQgdG8gdGhlIHRyYW5zZmVyIHN0YXRpbmc6Cnt7bWVtb319Cnt7L2lmfX0KCklmIHt7dG99fSBkb2VzIG5vdCBoYXZlIGEgYmFsYW5jZSBmb3Ige3thc3NldF90b19zeW1ib2xfY29kZSBxdWFudGl0eX19LCBvciB0aGUgdG9rZW4gbWFuYWdlciBkb2VzIG5vdCBoYXZlIGEgYmFsYW5jZSBmb3Ige3thc3NldF90b19zeW1ib2xfY29kZSBxdWFudGl0eX19LCB0aGUgdG9rZW4gbWFuYWdlciB3aWxsIGJlIGRlc2lnbmF0ZWQgYXMgdGhlIFJBTSBwYXllciBvZiB0aGUge3thc3NldF90b19zeW1ib2xfY29kZSBxdWFudGl0eX19IHRva2VuIGJhbGFuY2UgZm9yIHt7dG99fS4gQXMgYSByZXN1bHQsIFJBTSB3aWxsIGJlIGRlZHVjdGVkIGZyb20gdGhlIHRva2VuIG1hbmFnZXLigJlzIHJlc291cmNlcyB0byBjcmVhdGUgdGhlIG5lY2Vzc2FyeSByZWNvcmRzLgoKVGhpcyBhY3Rpb24gZG9lcyBub3QgYWxsb3cgdGhlIHRvdGFsIHF1YW50aXR5IHRvIGV4Y2VlZCB0aGUgbWF4IGFsbG93ZWQgc3VwcGx5IG9mIHRoZSB0b2tlbi4AQFLdLaUxdgppc3N1ZWZpeGVkoQgtLS0Kc3BlY192ZXJzaW9uOiAiMC4yLjAiCnRpdGxlOiBJc3N1ZSBGaXhlZCBTdXBwbHkgb2YgVG9rZW5zIGludG8gQ2lyY3VsYXRpb24Kc3VtbWFyeTogJ0lzc3VlIHVwIHRvIHt7bm93cmFwIHN1cHBseX19IHN1cHBseSBpbnRvIGNpcmN1bGF0aW9uIGFuZCB0cmFuc2ZlciBpbnRvIHt7bm93cmFwIHRvfX3igJlzIGFjY291bnQnCmljb246IGh0dHBzOi8vcmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbS9lb3NuZXR3b3JrZm91bmRhdGlvbi9lb3Mtc3lzdGVtLWNvbnRyYWN0cy9tYWluL2NvbnRyYWN0cy9pY29ucy90b2tlbi5wbmcjMjA3ZmY2OGIwNDA2ZWFhNTY2MThiMDhiZGE4MWQ2YTA5NTQ1NDNmMzZhZGMzMjhhYjMwNjVmMzFhNWM1ZDY1NAotLS0KClRoZSB0b2tlbiBtYW5hZ2VyIGFncmVlcyB0byBpc3N1ZSB0b2tlbnMgdXAgdG8ge3tzdXBwbHl9fSBmaXhlZCBzdXBwbHkgaW50byBjaXJjdWxhdGlvbiwgYW5kIHRyYW5zZmVyIGl0IGludG8ge3t0b3194oCZcyBhY2NvdW50LgoKe3sjaWYgbWVtb319VGhlcmUgaXMgYSBtZW1vIGF0dGFjaGVkIHRvIHRoZSB0cmFuc2ZlciBzdGF0aW5nOgp7e21lbW99fQp7ey9pZn19CgpJZiB7e3RvfX0gZG9lcyBub3QgaGF2ZSBhIGJhbGFuY2UgZm9yIHt7YXNzZXRfdG9fc3ltYm9sX2NvZGUgcXVhbnRpdHl9fSwgb3IgdGhlIHRva2VuIG1hbmFnZXIgZG9lcyBub3QgaGF2ZSBhIGJhbGFuY2UgZm9yIHt7YXNzZXRfdG9fc3ltYm9sX2NvZGUgcXVhbnRpdHl9fSwgdGhlIHRva2VuIG1hbmFnZXIgd2lsbCBiZSBkZXNpZ25hdGVkIGFzIHRoZSBSQU0gcGF5ZXIgb2YgdGhlIHt7YXNzZXRfdG9fc3ltYm9sX2NvZGUgcXVhbnRpdHl9fSB0b2tlbiBiYWxhbmNlIGZvciB7e3RvfX0uIEFzIGEgcmVzdWx0LCBSQU0gd2lsbCBiZSBkZWR1Y3RlZCBmcm9tIHRoZSB0b2tlbiBtYW5hZ2Vy4oCZcyByZXNvdXJjZXMgdG8gY3JlYXRlIHRoZSBuZWNlc3NhcnkgcmVjb3Jkcy4KClRoaXMgYWN0aW9uIGRvZXMgbm90IGFsbG93IHRoZSB0b3RhbCBxdWFudGl0eSB0byBleGNlZWQgdGhlIG1heCBhbGxvd2VkIHN1cHBseSBvZiB0aGUgdG9rZW4uAAAAAAAwVaUEb3BlbsYFLS0tCnNwZWNfdmVyc2lvbjogIjAuMi4wIgp0aXRsZTogT3BlbiBUb2tlbiBCYWxhbmNlCnN1bW1hcnk6ICdPcGVuIGEgemVybyBxdWFudGl0eSBiYWxhbmNlIGZvciB7e25vd3JhcCBvd25lcn19JwppY29uOiBodHRwczovL3Jhdy5naXRodWJ1c2VyY29udGVudC5jb20vZW9zbmV0d29ya2ZvdW5kYXRpb24vZW9zLXN5c3RlbS1jb250cmFjdHMvbWFpbi9jb250cmFjdHMvaWNvbnMvdG9rZW4ucG5nIzIwN2ZmNjhiMDQwNmVhYTU2NjE4YjA4YmRhODFkNmEwOTU0NTQzZjM2YWRjMzI4YWIzMDY1ZjMxYTVjNWQ2NTQKLS0tCgp7e3JhbV9wYXllcn19IGFncmVlcyB0byBlc3RhYmxpc2ggYSB6ZXJvIHF1YW50aXR5IGJhbGFuY2UgZm9yIHt7b3duZXJ9fSBmb3IgdGhlIHt7c3ltYm9sX3RvX3N5bWJvbF9jb2RlIHN5bWJvbH19IHRva2VuLgoKSWYge3tvd25lcn19IGRvZXMgbm90IGhhdmUgYSBiYWxhbmNlIGZvciB7e3N5bWJvbF90b19zeW1ib2xfY29kZSBzeW1ib2x9fSwge3tyYW1fcGF5ZXJ9fSB3aWxsIGJlIGRlc2lnbmF0ZWQgYXMgdGhlIFJBTSBwYXllciBvZiB0aGUge3tzeW1ib2xfdG9fc3ltYm9sX2NvZGUgc3ltYm9sfX0gdG9rZW4gYmFsYW5jZSBmb3Ige3tvd25lcn19LiBBcyBhIHJlc3VsdCwgUkFNIHdpbGwgYmUgZGVkdWN0ZWQgZnJvbSB7e3JhbV9wYXllcn194oCZcyByZXNvdXJjZXMgdG8gY3JlYXRlIHRoZSBuZWNlc3NhcnkgcmVjb3Jkcy4AAAAAqOuyugZyZXRpcmXcAy0tLQpzcGVjX3ZlcnNpb246ICIwLjIuMCIKdGl0bGU6IFJlbW92ZSBUb2tlbnMgZnJvbSBDaXJjdWxhdGlvbgpzdW1tYXJ5OiAnUmVtb3ZlIHt7bm93cmFwIHF1YW50aXR5fX0gZnJvbSBjaXJjdWxhdGlvbicKaWNvbjogaHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2Vvc25ldHdvcmtmb3VuZGF0aW9uL2Vvcy1zeXN0ZW0tY29udHJhY3RzL21haW4vY29udHJhY3RzL2ljb25zL3Rva2VuLnBuZyMyMDdmZjY4YjA0MDZlYWE1NjYxOGIwOGJkYTgxZDZhMDk1NDU0M2YzNmFkYzMyOGFiMzA2NWYzMWE1YzVkNjU0Ci0tLQoKVGhlIHRva2VuIG1hbmFnZXIgYWdyZWVzIHRvIHJlbW92ZSB7e3F1YW50aXR5fX0gZnJvbSBjaXJjdWxhdGlvbiwgdGFrZW4gZnJvbSB0aGVpciBvd24gYWNjb3VudC4KCnt7I2lmIG1lbW99fSBUaGVyZSBpcyBhIG1lbW8gYXR0YWNoZWQgdG8gdGhlIGFjdGlvbiBzdGF0aW5nOgp7e21lbW99fQp7ey9pZn194GOtGncjs8IMc2V0bWF4c3VwcGx5wAMtLS0Kc3BlY192ZXJzaW9uOiAiMC4yLjAiCnRpdGxlOiBTZXQgTWF4IFN1cHBseQpzdW1tYXJ5OiAnU2V0IG1heCBzdXBwbHkgZm9yIHRva2VuJwppY29uOiBodHRwczovL3Jhdy5naXRodWJ1c2VyY29udGVudC5jb20vZW9zbmV0d29ya2ZvdW5kYXRpb24vZW9zLXN5c3RlbS1jb250cmFjdHMvbWFpbi9jb250cmFjdHMvaWNvbnMvdG9rZW4ucG5nIzIwN2ZmNjhiMDQwNmVhYTU2NjE4YjA4YmRhODFkNmEwOTU0NTQzZjM2YWRjMzI4YWIzMDY1ZjMxYTVjNWQ2NTQKLS0tCgp7e2lzc3Vlcn19IHdpbGwgYmUgYWxsb3dlZCB0byBpc3N1ZSB0b2tlbnMgaW50byBjaXJjdWxhdGlvbiwgdXAgdG8gYSBtYXhpbXVtIHN1cHBseSBvZiB7e21heGltdW1fc3VwcGx5fX0uCgpUaGlzIGFjdGlvbiB3aWxsIG5vdCByZXN1bHQgYW55IGFueSB0b2tlbnMgYmVpbmcgaXNzdWVkIGludG8gY2lyY3VsYXRpb24uAAAAVy08zc0IdHJhbnNmZXK2By0tLQpzcGVjX3ZlcnNpb246ICIwLjIuMCIKdGl0bGU6IFRyYW5zZmVyIFRva2VucwpzdW1tYXJ5OiAnU2VuZCB7e25vd3JhcCBxdWFudGl0eX19IGZyb20ge3tub3dyYXAgZnJvbX19IHRvIHt7bm93cmFwIHRvfX0nCmljb246IGh0dHBzOi8vcmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbS9lb3NuZXR3b3JrZm91bmRhdGlvbi9lb3Mtc3lzdGVtLWNvbnRyYWN0cy9tYWluL2NvbnRyYWN0cy9pY29ucy90cmFuc2Zlci5wbmcjNWRmYWQwZGY3Mjc3MmVlMWNjYzE1NWU2NzBjMWQxMjRmNWM1MTIyZjFkNTAyNzU2NWRmMzhiNDE4MDQyZDFkZAotLS0KCnt7ZnJvbX19IGFncmVlcyB0byBzZW5kIHt7cXVhbnRpdHl9fSB0byB7e3RvfX0uCgp7eyNpZiBtZW1vfX1UaGVyZSBpcyBhIG1lbW8gYXR0YWNoZWQgdG8gdGhlIHRyYW5zZmVyIHN0YXRpbmc6Cnt7bWVtb319Cnt7L2lmfX0KCklmIHt7ZnJvbX19IGlzIG5vdCBhbHJlYWR5IHRoZSBSQU0gcGF5ZXIgb2YgdGhlaXIge3thc3NldF90b19zeW1ib2xfY29kZSBxdWFudGl0eX19IHRva2VuIGJhbGFuY2UsIHt7ZnJvbX19IHdpbGwgYmUgZGVzaWduYXRlZCBhcyBzdWNoLiBBcyBhIHJlc3VsdCwgUkFNIHdpbGwgYmUgZGVkdWN0ZWQgZnJvbSB7e2Zyb219feKAmXMgcmVzb3VyY2VzIHRvIHJlZnVuZCB0aGUgb3JpZ2luYWwgUkFNIHBheWVyLgoKSWYge3t0b319IGRvZXMgbm90IGhhdmUgYSBiYWxhbmNlIGZvciB7e2Fzc2V0X3RvX3N5bWJvbF9jb2RlIHF1YW50aXR5fX0sIHt7ZnJvbX19IHdpbGwgYmUgZGVzaWduYXRlZCBhcyB0aGUgUkFNIHBheWVyIG9mIHRoZSB7e2Fzc2V0X3RvX3N5bWJvbF9jb2RlIHF1YW50aXR5fX0gdG9rZW4gYmFsYW5jZSBmb3Ige3t0b319LiBBcyBhIHJlc3VsdCwgUkFNIHdpbGwgYmUgZGVkdWN0ZWQgZnJvbSB7e2Zyb219feKAmXMgcmVzb3VyY2VzIHRvIGNyZWF0ZSB0aGUgbmVjZXNzYXJ5IHJlY29yZHMuAgAAADhPTREyA2k2NAAAB2FjY291bnQAAAAAAJBNxgNpNjQAAA5jdXJyZW5jeV9zdGF0cwAAAAA="
+ },
+ "text": "{\"account_name\":\"eosio.token\",\"code_hash\":\"0a16e1dac533c4558698c8754f41219839ba2a2b75e517e65ea2537f76681f49\",\"abi_hash\":\"4ddeabf8085f6a6045ec75a100d93b21f6979668d2f7ade18812a5698c6613ce\",\"abi\":\"DmVvc2lvOjphYmkvMS4yAAoHYWNjb3VudAABB2JhbGFuY2UFYXNzZXQFY2xvc2UAAgVvd25lcgRuYW1lBnN5bWJvbAZzeW1ib2wGY3JlYXRlAAIGaXNzdWVyBG5hbWUObWF4aW11bV9zdXBwbHkFYXNzZXQOY3VycmVuY3lfc3RhdHMAAwZzdXBwbHkFYXNzZXQKbWF4X3N1cHBseQVhc3NldAZpc3N1ZXIEbmFtZQVpc3N1ZQADAnRvBG5hbWUIcXVhbnRpdHkFYXNzZXQEbWVtbwZzdHJpbmcKaXNzdWVmaXhlZAADAnRvBG5hbWUGc3VwcGx5BWFzc2V0BG1lbW8Gc3RyaW5nBG9wZW4AAwVvd25lcgRuYW1lBnN5bWJvbAZzeW1ib2wJcmFtX3BheWVyBG5hbWUGcmV0aXJlAAIIcXVhbnRpdHkFYXNzZXQEbWVtbwZzdHJpbmcMc2V0bWF4c3VwcGx5AAIGaXNzdWVyBG5hbWUObWF4aW11bV9zdXBwbHkFYXNzZXQIdHJhbnNmZXIABARmcm9tBG5hbWUCdG8EbmFtZQhxdWFudGl0eQVhc3NldARtZW1vBnN0cmluZwgAAAAAAIVpRAVjbG9zZfkDLS0tCnNwZWNfdmVyc2lvbjogIjAuMi4wIgp0aXRsZTogQ2xvc2UgVG9rZW4gQmFsYW5jZQpzdW1tYXJ5OiAnQ2xvc2Uge3tub3dyYXAgb3duZXJ9feKAmXMgemVybyBxdWFudGl0eSBiYWxhbmNlJwppY29uOiBodHRwczovL3Jhdy5naXRodWJ1c2VyY29udGVudC5jb20vZW9zbmV0d29ya2ZvdW5kYXRpb24vZW9zLXN5c3RlbS1jb250cmFjdHMvbWFpbi9jb250cmFjdHMvaWNvbnMvdG9rZW4ucG5nIzIwN2ZmNjhiMDQwNmVhYTU2NjE4YjA4YmRhODFkNmEwOTU0NTQzZjM2YWRjMzI4YWIzMDY1ZjMxYTVjNWQ2NTQKLS0tCgp7e293bmVyfX0gYWdyZWVzIHRvIGNsb3NlIHRoZWlyIHplcm8gcXVhbnRpdHkgYmFsYW5jZSBmb3IgdGhlIHt7c3ltYm9sX3RvX3N5bWJvbF9jb2RlIHN5bWJvbH19IHRva2VuLgoKUkFNIHdpbGwgYmUgcmVmdW5kZWQgdG8gdGhlIFJBTSBwYXllciBvZiB0aGUge3tzeW1ib2xfdG9fc3ltYm9sX2NvZGUgc3ltYm9sfX0gdG9rZW4gYmFsYW5jZSBmb3Ige3tvd25lcn19LgAAAACobNRFBmNyZWF0ZZoFLS0tCnNwZWNfdmVyc2lvbjogIjAuMi4wIgp0aXRsZTogQ3JlYXRlIE5ldyBUb2tlbgpzdW1tYXJ5OiAnQ3JlYXRlIGEgbmV3IHRva2VuJwppY29uOiBodHRwczovL3Jhdy5naXRodWJ1c2VyY29udGVudC5jb20vZW9zbmV0d29ya2ZvdW5kYXRpb24vZW9zLXN5c3RlbS1jb250cmFjdHMvbWFpbi9jb250cmFjdHMvaWNvbnMvdG9rZW4ucG5nIzIwN2ZmNjhiMDQwNmVhYTU2NjE4YjA4YmRhODFkNmEwOTU0NTQzZjM2YWRjMzI4YWIzMDY1ZjMxYTVjNWQ2NTQKLS0tCgp7eyRhY3Rpb24uYWNjb3VudH19IGFncmVlcyB0byBjcmVhdGUgYSBuZXcgdG9rZW4gd2l0aCBzeW1ib2wge3thc3NldF90b19zeW1ib2xfY29kZSBtYXhpbXVtX3N1cHBseX19IHRvIGJlIG1hbmFnZWQgYnkge3tpc3N1ZXJ9fS4KClRoaXMgYWN0aW9uIHdpbGwgbm90IHJlc3VsdCBhbnkgYW55IHRva2VucyBiZWluZyBpc3N1ZWQgaW50byBjaXJjdWxhdGlvbi4KCnt7aXNzdWVyfX0gd2lsbCBiZSBhbGxvd2VkIHRvIGlzc3VlIHRva2VucyBpbnRvIGNpcmN1bGF0aW9uLCB1cCB0byBhIG1heGltdW0gc3VwcGx5IG9mIHt7bWF4aW11bV9zdXBwbHl9fS4KClJBTSB3aWxsIGRlZHVjdGVkIGZyb20ge3skYWN0aW9uLmFjY291bnR9feKAmXMgcmVzb3VyY2VzIHRvIGNyZWF0ZSB0aGUgbmVjZXNzYXJ5IHJlY29yZHMuAAAAAAClMXYFaXNzdWXuBy0tLQpzcGVjX3ZlcnNpb246ICIwLjIuMCIKdGl0bGU6IElzc3VlIFRva2VucyBpbnRvIENpcmN1bGF0aW9uCnN1bW1hcnk6ICdJc3N1ZSB7e25vd3JhcCBxdWFudGl0eX19IGludG8gY2lyY3VsYXRpb24gYW5kIHRyYW5zZmVyIGludG8ge3tub3dyYXAgdG99feKAmXMgYWNjb3VudCcKaWNvbjogaHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2Vvc25ldHdvcmtmb3VuZGF0aW9uL2Vvcy1zeXN0ZW0tY29udHJhY3RzL21haW4vY29udHJhY3RzL2ljb25zL3Rva2VuLnBuZyMyMDdmZjY4YjA0MDZlYWE1NjYxOGIwOGJkYTgxZDZhMDk1NDU0M2YzNmFkYzMyOGFiMzA2NWYzMWE1YzVkNjU0Ci0tLQoKVGhlIHRva2VuIG1hbmFnZXIgYWdyZWVzIHRvIGlzc3VlIHt7cXVhbnRpdHl9fSBpbnRvIGNpcmN1bGF0aW9uLCBhbmQgdHJhbnNmZXIgaXQgaW50byB7e3RvfX3igJlzIGFjY291bnQuCgp7eyNpZiBtZW1vfX1UaGVyZSBpcyBhIG1lbW8gYXR0YWNoZWQgdG8gdGhlIHRyYW5zZmVyIHN0YXRpbmc6Cnt7bWVtb319Cnt7L2lmfX0KCklmIHt7dG99fSBkb2VzIG5vdCBoYXZlIGEgYmFsYW5jZSBmb3Ige3thc3NldF90b19zeW1ib2xfY29kZSBxdWFudGl0eX19LCBvciB0aGUgdG9rZW4gbWFuYWdlciBkb2VzIG5vdCBoYXZlIGEgYmFsYW5jZSBmb3Ige3thc3NldF90b19zeW1ib2xfY29kZSBxdWFudGl0eX19LCB0aGUgdG9rZW4gbWFuYWdlciB3aWxsIGJlIGRlc2lnbmF0ZWQgYXMgdGhlIFJBTSBwYXllciBvZiB0aGUge3thc3NldF90b19zeW1ib2xfY29kZSBxdWFudGl0eX19IHRva2VuIGJhbGFuY2UgZm9yIHt7dG99fS4gQXMgYSByZXN1bHQsIFJBTSB3aWxsIGJlIGRlZHVjdGVkIGZyb20gdGhlIHRva2VuIG1hbmFnZXLigJlzIHJlc291cmNlcyB0byBjcmVhdGUgdGhlIG5lY2Vzc2FyeSByZWNvcmRzLgoKVGhpcyBhY3Rpb24gZG9lcyBub3QgYWxsb3cgdGhlIHRvdGFsIHF1YW50aXR5IHRvIGV4Y2VlZCB0aGUgbWF4IGFsbG93ZWQgc3VwcGx5IG9mIHRoZSB0b2tlbi4AQFLdLaUxdgppc3N1ZWZpeGVkoQgtLS0Kc3BlY192ZXJzaW9uOiAiMC4yLjAiCnRpdGxlOiBJc3N1ZSBGaXhlZCBTdXBwbHkgb2YgVG9rZW5zIGludG8gQ2lyY3VsYXRpb24Kc3VtbWFyeTogJ0lzc3VlIHVwIHRvIHt7bm93cmFwIHN1cHBseX19IHN1cHBseSBpbnRvIGNpcmN1bGF0aW9uIGFuZCB0cmFuc2ZlciBpbnRvIHt7bm93cmFwIHRvfX3igJlzIGFjY291bnQnCmljb246IGh0dHBzOi8vcmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbS9lb3NuZXR3b3JrZm91bmRhdGlvbi9lb3Mtc3lzdGVtLWNvbnRyYWN0cy9tYWluL2NvbnRyYWN0cy9pY29ucy90b2tlbi5wbmcjMjA3ZmY2OGIwNDA2ZWFhNTY2MThiMDhiZGE4MWQ2YTA5NTQ1NDNmMzZhZGMzMjhhYjMwNjVmMzFhNWM1ZDY1NAotLS0KClRoZSB0b2tlbiBtYW5hZ2VyIGFncmVlcyB0byBpc3N1ZSB0b2tlbnMgdXAgdG8ge3tzdXBwbHl9fSBmaXhlZCBzdXBwbHkgaW50byBjaXJjdWxhdGlvbiwgYW5kIHRyYW5zZmVyIGl0IGludG8ge3t0b3194oCZcyBhY2NvdW50LgoKe3sjaWYgbWVtb319VGhlcmUgaXMgYSBtZW1vIGF0dGFjaGVkIHRvIHRoZSB0cmFuc2ZlciBzdGF0aW5nOgp7e21lbW99fQp7ey9pZn19CgpJZiB7e3RvfX0gZG9lcyBub3QgaGF2ZSBhIGJhbGFuY2UgZm9yIHt7YXNzZXRfdG9fc3ltYm9sX2NvZGUgcXVhbnRpdHl9fSwgb3IgdGhlIHRva2VuIG1hbmFnZXIgZG9lcyBub3QgaGF2ZSBhIGJhbGFuY2UgZm9yIHt7YXNzZXRfdG9fc3ltYm9sX2NvZGUgcXVhbnRpdHl9fSwgdGhlIHRva2VuIG1hbmFnZXIgd2lsbCBiZSBkZXNpZ25hdGVkIGFzIHRoZSBSQU0gcGF5ZXIgb2YgdGhlIHt7YXNzZXRfdG9fc3ltYm9sX2NvZGUgcXVhbnRpdHl9fSB0b2tlbiBiYWxhbmNlIGZvciB7e3RvfX0uIEFzIGEgcmVzdWx0LCBSQU0gd2lsbCBiZSBkZWR1Y3RlZCBmcm9tIHRoZSB0b2tlbiBtYW5hZ2Vy4oCZcyByZXNvdXJjZXMgdG8gY3JlYXRlIHRoZSBuZWNlc3NhcnkgcmVjb3Jkcy4KClRoaXMgYWN0aW9uIGRvZXMgbm90IGFsbG93IHRoZSB0b3RhbCBxdWFudGl0eSB0byBleGNlZWQgdGhlIG1heCBhbGxvd2VkIHN1cHBseSBvZiB0aGUgdG9rZW4uAAAAAAAwVaUEb3BlbsYFLS0tCnNwZWNfdmVyc2lvbjogIjAuMi4wIgp0aXRsZTogT3BlbiBUb2tlbiBCYWxhbmNlCnN1bW1hcnk6ICdPcGVuIGEgemVybyBxdWFudGl0eSBiYWxhbmNlIGZvciB7e25vd3JhcCBvd25lcn19JwppY29uOiBodHRwczovL3Jhdy5naXRodWJ1c2VyY29udGVudC5jb20vZW9zbmV0d29ya2ZvdW5kYXRpb24vZW9zLXN5c3RlbS1jb250cmFjdHMvbWFpbi9jb250cmFjdHMvaWNvbnMvdG9rZW4ucG5nIzIwN2ZmNjhiMDQwNmVhYTU2NjE4YjA4YmRhODFkNmEwOTU0NTQzZjM2YWRjMzI4YWIzMDY1ZjMxYTVjNWQ2NTQKLS0tCgp7e3JhbV9wYXllcn19IGFncmVlcyB0byBlc3RhYmxpc2ggYSB6ZXJvIHF1YW50aXR5IGJhbGFuY2UgZm9yIHt7b3duZXJ9fSBmb3IgdGhlIHt7c3ltYm9sX3RvX3N5bWJvbF9jb2RlIHN5bWJvbH19IHRva2VuLgoKSWYge3tvd25lcn19IGRvZXMgbm90IGhhdmUgYSBiYWxhbmNlIGZvciB7e3N5bWJvbF90b19zeW1ib2xfY29kZSBzeW1ib2x9fSwge3tyYW1fcGF5ZXJ9fSB3aWxsIGJlIGRlc2lnbmF0ZWQgYXMgdGhlIFJBTSBwYXllciBvZiB0aGUge3tzeW1ib2xfdG9fc3ltYm9sX2NvZGUgc3ltYm9sfX0gdG9rZW4gYmFsYW5jZSBmb3Ige3tvd25lcn19LiBBcyBhIHJlc3VsdCwgUkFNIHdpbGwgYmUgZGVkdWN0ZWQgZnJvbSB7e3JhbV9wYXllcn194oCZcyByZXNvdXJjZXMgdG8gY3JlYXRlIHRoZSBuZWNlc3NhcnkgcmVjb3Jkcy4AAAAAqOuyugZyZXRpcmXcAy0tLQpzcGVjX3ZlcnNpb246ICIwLjIuMCIKdGl0bGU6IFJlbW92ZSBUb2tlbnMgZnJvbSBDaXJjdWxhdGlvbgpzdW1tYXJ5OiAnUmVtb3ZlIHt7bm93cmFwIHF1YW50aXR5fX0gZnJvbSBjaXJjdWxhdGlvbicKaWNvbjogaHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2Vvc25ldHdvcmtmb3VuZGF0aW9uL2Vvcy1zeXN0ZW0tY29udHJhY3RzL21haW4vY29udHJhY3RzL2ljb25zL3Rva2VuLnBuZyMyMDdmZjY4YjA0MDZlYWE1NjYxOGIwOGJkYTgxZDZhMDk1NDU0M2YzNmFkYzMyOGFiMzA2NWYzMWE1YzVkNjU0Ci0tLQoKVGhlIHRva2VuIG1hbmFnZXIgYWdyZWVzIHRvIHJlbW92ZSB7e3F1YW50aXR5fX0gZnJvbSBjaXJjdWxhdGlvbiwgdGFrZW4gZnJvbSB0aGVpciBvd24gYWNjb3VudC4KCnt7I2lmIG1lbW99fSBUaGVyZSBpcyBhIG1lbW8gYXR0YWNoZWQgdG8gdGhlIGFjdGlvbiBzdGF0aW5nOgp7e21lbW99fQp7ey9pZn194GOtGncjs8IMc2V0bWF4c3VwcGx5wAMtLS0Kc3BlY192ZXJzaW9uOiAiMC4yLjAiCnRpdGxlOiBTZXQgTWF4IFN1cHBseQpzdW1tYXJ5OiAnU2V0IG1heCBzdXBwbHkgZm9yIHRva2VuJwppY29uOiBodHRwczovL3Jhdy5naXRodWJ1c2VyY29udGVudC5jb20vZW9zbmV0d29ya2ZvdW5kYXRpb24vZW9zLXN5c3RlbS1jb250cmFjdHMvbWFpbi9jb250cmFjdHMvaWNvbnMvdG9rZW4ucG5nIzIwN2ZmNjhiMDQwNmVhYTU2NjE4YjA4YmRhODFkNmEwOTU0NTQzZjM2YWRjMzI4YWIzMDY1ZjMxYTVjNWQ2NTQKLS0tCgp7e2lzc3Vlcn19IHdpbGwgYmUgYWxsb3dlZCB0byBpc3N1ZSB0b2tlbnMgaW50byBjaXJjdWxhdGlvbiwgdXAgdG8gYSBtYXhpbXVtIHN1cHBseSBvZiB7e21heGltdW1fc3VwcGx5fX0uCgpUaGlzIGFjdGlvbiB3aWxsIG5vdCByZXN1bHQgYW55IGFueSB0b2tlbnMgYmVpbmcgaXNzdWVkIGludG8gY2lyY3VsYXRpb24uAAAAVy08zc0IdHJhbnNmZXK2By0tLQpzcGVjX3ZlcnNpb246ICIwLjIuMCIKdGl0bGU6IFRyYW5zZmVyIFRva2VucwpzdW1tYXJ5OiAnU2VuZCB7e25vd3JhcCBxdWFudGl0eX19IGZyb20ge3tub3dyYXAgZnJvbX19IHRvIHt7bm93cmFwIHRvfX0nCmljb246IGh0dHBzOi8vcmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbS9lb3NuZXR3b3JrZm91bmRhdGlvbi9lb3Mtc3lzdGVtLWNvbnRyYWN0cy9tYWluL2NvbnRyYWN0cy9pY29ucy90cmFuc2Zlci5wbmcjNWRmYWQwZGY3Mjc3MmVlMWNjYzE1NWU2NzBjMWQxMjRmNWM1MTIyZjFkNTAyNzU2NWRmMzhiNDE4MDQyZDFkZAotLS0KCnt7ZnJvbX19IGFncmVlcyB0byBzZW5kIHt7cXVhbnRpdHl9fSB0byB7e3RvfX0uCgp7eyNpZiBtZW1vfX1UaGVyZSBpcyBhIG1lbW8gYXR0YWNoZWQgdG8gdGhlIHRyYW5zZmVyIHN0YXRpbmc6Cnt7bWVtb319Cnt7L2lmfX0KCklmIHt7ZnJvbX19IGlzIG5vdCBhbHJlYWR5IHRoZSBSQU0gcGF5ZXIgb2YgdGhlaXIge3thc3NldF90b19zeW1ib2xfY29kZSBxdWFudGl0eX19IHRva2VuIGJhbGFuY2UsIHt7ZnJvbX19IHdpbGwgYmUgZGVzaWduYXRlZCBhcyBzdWNoLiBBcyBhIHJlc3VsdCwgUkFNIHdpbGwgYmUgZGVkdWN0ZWQgZnJvbSB7e2Zyb219feKAmXMgcmVzb3VyY2VzIHRvIHJlZnVuZCB0aGUgb3JpZ2luYWwgUkFNIHBheWVyLgoKSWYge3t0b319IGRvZXMgbm90IGhhdmUgYSBiYWxhbmNlIGZvciB7e2Fzc2V0X3RvX3N5bWJvbF9jb2RlIHF1YW50aXR5fX0sIHt7ZnJvbX19IHdpbGwgYmUgZGVzaWduYXRlZCBhcyB0aGUgUkFNIHBheWVyIG9mIHRoZSB7e2Fzc2V0X3RvX3N5bWJvbF9jb2RlIHF1YW50aXR5fX0gdG9rZW4gYmFsYW5jZSBmb3Ige3t0b319LiBBcyBhIHJlc3VsdCwgUkFNIHdpbGwgYmUgZGVkdWN0ZWQgZnJvbSB7e2Zyb219feKAmXMgcmVzb3VyY2VzIHRvIGNyZWF0ZSB0aGUgbmVjZXNzYXJ5IHJlY29yZHMuAgAAADhPTREyA2k2NAAAB2FjY291bnQAAAAAAJBNxgNpNjQAAA5jdXJyZW5jeV9zdGF0cwAAAAA=\"}"
+}
\ No newline at end of file
diff --git a/packages/wallet-plugin-tacklebox/test/tests/common.ts b/packages/wallet-plugin-tacklebox/test/tests/common.ts
new file mode 100644
index 00000000..c56bbc7e
--- /dev/null
+++ b/packages/wallet-plugin-tacklebox/test/tests/common.ts
@@ -0,0 +1,76 @@
+import {assert} from 'chai'
+import * as zlib from 'pako'
+import {AbstractWalletPlugin, SigningRequest} from '@wharfkit/session'
+import {mockChainId} from '@wharfkit/mock-data'
+
+import {copyToClipboard, DEFAULT_BUOY_URL, tackleboxDeepLink, WalletPluginTackleBox} from '$lib'
+
+suite('plugin surface', function () {
+ test('is a wallet plugin with the expected id', function () {
+ const plugin = new WalletPluginTackleBox()
+ assert.instanceOf(plugin, AbstractWalletPlugin as any)
+ assert.equal(plugin.id, 'tacklebox')
+ })
+
+ test('lets the wallet pick the chain and permission', function () {
+ const plugin = new WalletPluginTackleBox()
+ assert.isFalse(plugin.config.requiresChainSelect)
+ assert.isFalse(plugin.config.requiresPermissionSelect)
+ assert.isUndefined(plugin.config.supportedChains)
+ })
+
+ test('carries TackleBox metadata', function () {
+ const {metadata} = new WalletPluginTackleBox()
+ assert.equal(metadata.name, 'TackleBox')
+ assert.equal(metadata.homepage, 'https://github.com/doyen-games/tacklebox')
+ assert.equal(metadata.download, 'https://github.com/doyen-games/tacklebox/releases')
+ assert.exists(metadata.logo)
+ assert.isTrue(String(metadata.logo!.getVariant('light')).startsWith('data:image/svg+xml'))
+ assert.isTrue(String(metadata.logo!.getVariant('dark')).startsWith('data:image/svg+xml'))
+ })
+
+ test('defaults to the shared buoy service', function () {
+ const plugin = new WalletPluginTackleBox()
+ assert.equal(plugin.buoyUrl, DEFAULT_BUOY_URL)
+ assert.equal(DEFAULT_BUOY_URL, 'https://cb.anchor.link')
+ })
+
+ test('accepts a custom buoy service', function () {
+ const plugin = new WalletPluginTackleBox({buoyUrl: 'https://buoy.example.com'})
+ assert.equal(plugin.buoyUrl, 'https://buoy.example.com')
+ })
+
+ test('ships english translations', function () {
+ const plugin = new WalletPluginTackleBox()
+ assert.exists(plugin.translations)
+ assert.equal(plugin.translations!.en.login.title, 'Connect with TackleBox')
+ assert.exists(plugin.translations!.en.error.cancelled)
+ })
+
+ test('clipboard helper degrades gracefully outside a browser', async function () {
+ assert.isFalse(await copyToClipboard('esr://test'))
+ })
+
+ test('deep links carry the request payload on the tacklebox scheme', function () {
+ const request = SigningRequest.identity(
+ {
+ callback: {url: 'https://cb.anchor.link/unittest', background: true},
+ scope: 'unittest',
+ chainId: mockChainId,
+ },
+ {zlib}
+ )
+ const link = tackleboxDeepLink(request)
+ assert.isTrue(link.startsWith('tacklebox://request/'))
+
+ const payload = link.slice('tacklebox://request/'.length)
+ const roundTrip = SigningRequest.from(`esr://${payload}`, {zlib})
+ assert.isTrue(roundTrip.isIdentity())
+ assert.equal(String(roundTrip.getIdentityScope()), 'unittest')
+ })
+
+ test('auto-launch can be disabled by option', function () {
+ assert.isTrue(new WalletPluginTackleBox().autoLaunch)
+ assert.isFalse(new WalletPluginTackleBox({disableAutoLaunch: true}).autoLaunch)
+ })
+})
diff --git a/packages/wallet-plugin-tacklebox/test/tests/login.ts b/packages/wallet-plugin-tacklebox/test/tests/login.ts
new file mode 100644
index 00000000..9ba1d089
--- /dev/null
+++ b/packages/wallet-plugin-tacklebox/test/tests/login.ts
@@ -0,0 +1,203 @@
+import {assert} from 'chai'
+import sinon from 'sinon'
+import * as zlib from 'pako'
+import {
+ ChainDefinition,
+ LoginContext,
+ PermissionLevel,
+ PromptElement,
+ SigningRequest,
+} from '@wharfkit/session'
+import {mockChainDefinition, mockChainId, mockFetch, mockPermissionLevel} from '@wharfkit/mock-data'
+
+import {WalletPluginTackleBox} from '$lib'
+import {makeMockUI} from '$test/utils/mock-ui'
+import {makeLoginCallbackPayload, mockChannelUrl, mockPublicKey} from '$test/utils/mock-esr'
+
+const chain = ChainDefinition.from(mockChainDefinition)
+
+function makeLoginContext(ui: any): LoginContext {
+ return {
+ chain,
+ chains: [chain],
+ ui,
+ fetch: mockFetch,
+ hooks: {},
+ appName: 'unittest',
+ permissionLevel: PermissionLevel.from(mockPermissionLevel),
+ walletPlugins: [],
+ arbitrary: {},
+ uiRequirements: {},
+ addHook: () => undefined,
+ getClient: () => undefined,
+ esrOptions: {zlib},
+ } as unknown as LoginContext
+}
+
+function stubBrowserWindow(): {location: {href: string}} {
+ const fakeWindow = {location: {href: 'http://localhost/unittest'}}
+ ;(global as any).window = fakeWindow
+ ;(global as any).navigator = {userAgent: 'mocha-unittest'}
+ return fakeWindow
+}
+
+function unstubBrowserWindow() {
+ delete (global as any).window
+ delete (global as any).navigator
+}
+
+/**
+ * The plugin takes its buoy callback wait as an internal seam, so a test
+ * substitutes the transport instead of stubbing a module export.
+ */
+function waitForCallbackStub(payload: any = makeLoginCallbackPayload()) {
+ return sinon.stub().resolves(payload)
+}
+
+suite('login', function () {
+ teardown(function () {
+ unstubBrowserWindow()
+ sinon.restore()
+ })
+
+ test('completes a login answered over the buoy channel', async function () {
+ const plugin = new WalletPluginTackleBox({waitForCallback: waitForCallbackStub()})
+ const ui = makeMockUI()
+ const response = await plugin.login(makeLoginContext(ui))
+
+ assert.equal(String(response.chain), mockChainId)
+ assert.equal(String(response.permissionLevel), mockPermissionLevel)
+ assert.exists(response.identityProof)
+ })
+
+ test('stores the wallet push channel for later signing', async function () {
+ const plugin = new WalletPluginTackleBox({waitForCallback: waitForCallbackStub()})
+ await plugin.login(makeLoginContext(makeMockUI()))
+
+ assert.equal(plugin.data.channelUrl, mockChannelUrl)
+ assert.equal(plugin.data.channelName, 'TackleBox')
+ assert.equal(String(plugin.data.signerKey), mockPublicKey)
+ assert.exists(plugin.data.privateKey)
+ assert.exists(plugin.data.requestKey)
+ })
+
+ test('session data survives a JSON round trip for restores', async function () {
+ const plugin = new WalletPluginTackleBox({waitForCallback: waitForCallbackStub()})
+ await plugin.login(makeLoginContext(makeMockUI()))
+
+ const restored = JSON.parse(JSON.stringify(plugin.data))
+ assert.equal(restored.channelUrl, mockChannelUrl)
+ assert.equal(restored.channelName, 'TackleBox')
+ assert.equal(String(restored.signerKey), mockPublicKey)
+ assert.isString(restored.privateKey)
+ })
+
+ test('still logs in when the wallet announces no channel', async function () {
+ const plugin = new WalletPluginTackleBox({
+ waitForCallback: waitForCallbackStub(makeLoginCallbackPayload({channel: false})),
+ })
+ const response = await plugin.login(makeLoginContext(makeMockUI()))
+
+ assert.equal(String(response.chain), mockChainId)
+ assert.isUndefined(plugin.data.channelUrl)
+ })
+
+ test('prompts with a QR code, a launch link and a copy fallback', async function () {
+ const plugin = new WalletPluginTackleBox({waitForCallback: waitForCallbackStub()})
+ const ui = makeMockUI()
+ await plugin.login(makeLoginContext(ui))
+
+ const args = ui.prompts[0]
+ assert.exists(args)
+ assert.equal(args.title, 'Connect with TackleBox')
+ const elements = args.elements as PromptElement[]
+ assert.deepEqual(
+ elements.map((e) => e.type),
+ ['qr', 'link', 'button']
+ )
+
+ // The launch link targets the wallet's own scheme.
+ const link: any = elements.find((e) => e.type === 'link')
+ assert.isTrue(String(link.data.href).startsWith('tacklebox://request/'))
+
+ // The rendered request is a valid ESR identity request.
+ const qr: any = elements.find((e) => e.type === 'qr')
+ assert.isTrue(String(qr.data).startsWith('esr://'))
+ const request = SigningRequest.from(String(qr.data), {zlib})
+ assert.isTrue(request.isIdentity())
+ })
+
+ test('opens TackleBox directly when a window exists', async function () {
+ const fakeWindow = stubBrowserWindow()
+
+ const plugin = new WalletPluginTackleBox({waitForCallback: waitForCallbackStub()})
+ await plugin.login(makeLoginContext(makeMockUI()))
+
+ assert.isTrue(fakeWindow.location.href.startsWith('tacklebox://request/'))
+ // The deep link payload decodes back into the identity request.
+ const payload = fakeWindow.location.href.slice('tacklebox://request/'.length)
+ const request = SigningRequest.from(`esr://${payload}`, {zlib})
+ assert.isTrue(request.isIdentity())
+ })
+
+ test('honors the disableAutoLaunch option', async function () {
+ const fakeWindow = stubBrowserWindow()
+
+ const plugin = new WalletPluginTackleBox({
+ disableAutoLaunch: true,
+ waitForCallback: waitForCallbackStub(),
+ })
+ const ui = makeMockUI()
+ await plugin.login(makeLoginContext(ui))
+
+ assert.equal(fakeWindow.location.href, 'http://localhost/unittest')
+ // The launch link stays available in the prompt.
+ const elements = ui.prompts[0].elements as PromptElement[]
+ assert.isTrue(elements.some((e) => e.type === 'link'))
+ })
+
+ test('listens for the callback on the configured buoy service', async function () {
+ const waitStub = waitForCallbackStub()
+
+ const plugin = new WalletPluginTackleBox({
+ buoyUrl: 'https://buoy.example.com',
+ waitForCallback: waitStub,
+ })
+ await plugin.login(makeLoginContext(makeMockUI()))
+
+ const receiveOptions: any = waitStub.firstCall.args[0]
+ assert.equal(receiveOptions.service, 'https://buoy.example.com')
+ assert.isString(receiveOptions.channel)
+ })
+
+ test('rejects when the wallet declines the request', async function () {
+ // A decline reaches the plugin as a rejection out of waitForCallback,
+ // which is where protocol-esr turns an empty payload into this error.
+ const plugin = new WalletPluginTackleBox({
+ waitForCallback: sinon
+ .stub()
+ .rejects(new Error('The request was cancelled from Anchor.')),
+ })
+ let error: Error | undefined
+ try {
+ await plugin.login(makeLoginContext(makeMockUI()))
+ } catch (err) {
+ error = err as Error
+ }
+ assert.exists(error)
+ assert.match(String(error!.message), /cancelled|declined/i)
+ })
+
+ test('requires a UI', async function () {
+ const plugin = new WalletPluginTackleBox()
+ const context = {...makeLoginContext(makeMockUI()), ui: undefined} as any
+ let error: Error | undefined
+ try {
+ await plugin.login(context)
+ } catch (err) {
+ error = err as Error
+ }
+ assert.exists(error)
+ assert.match(String(error!.message), /requires a UI/)
+ })
+})
diff --git a/packages/wallet-plugin-tacklebox/test/tests/sign.ts b/packages/wallet-plugin-tacklebox/test/tests/sign.ts
new file mode 100644
index 00000000..a0e13f7e
--- /dev/null
+++ b/packages/wallet-plugin-tacklebox/test/tests/sign.ts
@@ -0,0 +1,224 @@
+import fs from 'fs'
+import path from 'path'
+import {assert} from 'chai'
+import sinon from 'sinon'
+import {PermissionLevel, SessionKit} from '@wharfkit/session'
+import {
+ mockChainId,
+ mockPermissionLevel,
+ mockSessionKitArgs,
+ mockSessionKitOptions,
+ MockStorage,
+} from '@wharfkit/mock-data'
+
+import {WalletPluginTackleBox} from '$lib'
+import {makeMockUI, RecordingUserInterface} from '$test/utils/mock-ui'
+import {
+ makeLoginCallbackPayload,
+ makeTransactCallbackPayload,
+ mockChannelId,
+} from '$test/utils/mock-esr'
+
+const transferAction = {
+ authorization: [PermissionLevel.from(mockPermissionLevel)],
+ account: 'eosio.token',
+ name: 'transfer',
+ data: {
+ from: 'wharfkit1111',
+ to: 'wharfkittest',
+ quantity: '0.0001 EOS',
+ memo: 'wallet-plugin-tacklebox test',
+ },
+}
+
+/** The chain head time captured in the recorded get_info fixture. */
+function fixtureHeadTime(): number {
+ const dir = path.join(__dirname, '..', 'data')
+ for (const file of fs.readdirSync(dir)) {
+ const recorded = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
+ const head = recorded.json && recorded.json.head_block_time
+ if (head) {
+ return new Date(head + 'Z').getTime()
+ }
+ }
+ throw new Error('No recorded get_info fixture found in test/data')
+}
+
+function makeKit(plugin: WalletPluginTackleBox, ui: RecordingUserInterface) {
+ return new SessionKit(
+ {
+ ...mockSessionKitArgs,
+ ui,
+ walletPlugins: [plugin],
+ },
+ {
+ ...mockSessionKitOptions,
+ storage: new MockStorage(),
+ }
+ )
+}
+
+suite('sign', function () {
+ this.timeout(120 * 1000)
+ this.slow(5 * 1000)
+
+ let clock: sinon.SinonFakeTimers | undefined
+
+ setup(function () {
+ // TAPOS in these tests comes from the recorded chain head; pin "now"
+ // right after it so request expirations stay in the future no matter
+ // when the suite actually runs. Timers stay real.
+ clock = sinon.useFakeTimers({now: fixtureHeadTime() + 1000, toFake: ['Date']})
+ })
+
+ teardown(function () {
+ clock?.restore()
+ sinon.restore()
+ })
+
+ test('pushes the sealed request over the wallet channel and returns its signature', async function () {
+ const waitStub = sinon.stub()
+ const sendStub = sinon.stub()
+ waitStub.onFirstCall().resolves(makeLoginCallbackPayload())
+ waitStub
+ .onSecondCall()
+ .callsFake(async () => await makeTransactCallbackPayload(transferAction))
+
+ const plugin = new WalletPluginTackleBox({waitForCallback: waitStub, send: sendStub})
+ const ui = makeMockUI()
+ const kit = makeKit(plugin, ui)
+
+ const {session} = await kit.login({
+ chain: mockChainId,
+ permissionLevel: mockPermissionLevel,
+ })
+ assert.equal(String(session.actor), 'wharfkit1111')
+ assert.equal(String(session.permission), 'test')
+
+ const result = await session.transact({action: transferAction}, {broadcast: false})
+
+ assert.equal(result.signatures.length, 1)
+ assert.exists(result.resolved)
+ assert.equal(String(result.signer), mockPermissionLevel)
+
+ // The request went out sealed, addressed to the wallet's channel.
+ assert.isTrue(sendStub.calledOnce)
+ const [message, destination] = sendStub.firstCall.args as any[]
+ assert.instanceOf(message, Uint8Array)
+ assert.isAbove(message.length, 0)
+ assert.equal(destination.service, 'https://cb.anchor.link')
+ assert.equal(destination.channel, mockChannelId)
+ })
+
+ test('shows the channel prompt with a countdown and a manual fallback', async function () {
+ const waitStub = sinon.stub()
+ const sendStub = sinon.stub()
+ waitStub.onFirstCall().resolves(makeLoginCallbackPayload())
+ waitStub
+ .onSecondCall()
+ .callsFake(async () => await makeTransactCallbackPayload(transferAction))
+
+ const plugin = new WalletPluginTackleBox({waitForCallback: waitStub, send: sendStub})
+ const ui = makeMockUI()
+ const kit = makeKit(plugin, ui)
+ const {session} = await kit.login({
+ chain: mockChainId,
+ permissionLevel: mockPermissionLevel,
+ })
+ await session.transact({action: transferAction}, {broadcast: false})
+
+ const prompt = ui.prompts[ui.prompts.length - 1]
+ assert.equal(prompt.title, 'Sign with TackleBox')
+ assert.match(String(prompt.body), /TackleBox/)
+ assert.deepEqual(
+ (prompt.elements as any[]).map((e) => e.type),
+ ['countdown', 'link', 'button']
+ )
+ const link: any = (prompt.elements as any[]).find((e) => e.type === 'link')
+ assert.isTrue(String(link.data.href).startsWith('tacklebox://request/'))
+ })
+
+ test('falls back to the paste flow when no channel was announced', async function () {
+ const waitStub = sinon.stub()
+ const sendStub = sinon.stub()
+ waitStub.onFirstCall().resolves(makeLoginCallbackPayload({channel: false}))
+ waitStub
+ .onSecondCall()
+ .callsFake(async () => await makeTransactCallbackPayload(transferAction))
+
+ const plugin = new WalletPluginTackleBox({waitForCallback: waitStub, send: sendStub})
+ const ui = makeMockUI()
+ const kit = makeKit(plugin, ui)
+ const {session} = await kit.login({
+ chain: mockChainId,
+ permissionLevel: mockPermissionLevel,
+ })
+ const result = await session.transact({action: transferAction}, {broadcast: false})
+
+ assert.equal(result.signatures.length, 1)
+ assert.isTrue(sendStub.notCalled, 'nothing is pushed without a channel')
+
+ const prompt = ui.prompts[ui.prompts.length - 1]
+ const types = (prompt.elements as any[]).map((e) => e.type)
+ assert.deepEqual(types, ['qr', 'link', 'button', 'countdown'])
+ const qr: any = (prompt.elements as any[]).find((e) => e.type === 'qr')
+ assert.isTrue(String(qr.data).startsWith('esr://'))
+ })
+
+ test('opens TackleBox directly for channel-less signing when a window exists', async function () {
+ const waitStub = sinon.stub()
+ const sendStub = sinon.stub()
+ waitStub.onFirstCall().resolves(makeLoginCallbackPayload({channel: false}))
+ waitStub
+ .onSecondCall()
+ .callsFake(async () => await makeTransactCallbackPayload(transferAction))
+
+ const fakeWindow = {location: {href: 'http://localhost/unittest'}}
+ ;(global as any).window = fakeWindow
+ ;(global as any).navigator = {userAgent: 'mocha-unittest'}
+ try {
+ const plugin = new WalletPluginTackleBox({waitForCallback: waitStub, send: sendStub})
+ const kit = makeKit(plugin, makeMockUI())
+ const {session} = await kit.login({
+ chain: mockChainId,
+ permissionLevel: mockPermissionLevel,
+ })
+ fakeWindow.location.href = 'http://localhost/after-login'
+
+ await session.transact({action: transferAction}, {broadcast: false})
+ assert.isTrue(fakeWindow.location.href.startsWith('tacklebox://request/'))
+ } finally {
+ delete (global as any).window
+ delete (global as any).navigator
+ }
+ })
+
+ test('does not navigate when signing over the wallet channel', async function () {
+ const waitStub = sinon.stub()
+ const sendStub = sinon.stub()
+ waitStub.onFirstCall().resolves(makeLoginCallbackPayload())
+ waitStub
+ .onSecondCall()
+ .callsFake(async () => await makeTransactCallbackPayload(transferAction))
+
+ const fakeWindow = {location: {href: 'http://localhost/unittest'}}
+ ;(global as any).window = fakeWindow
+ ;(global as any).navigator = {userAgent: 'mocha-unittest'}
+ try {
+ const plugin = new WalletPluginTackleBox({waitForCallback: waitStub, send: sendStub})
+ const kit = makeKit(plugin, makeMockUI())
+ const {session} = await kit.login({
+ chain: mockChainId,
+ permissionLevel: mockPermissionLevel,
+ })
+ fakeWindow.location.href = 'http://localhost/after-login'
+
+ await session.transact({action: transferAction}, {broadcast: false})
+ // The wallet raises itself on the channel push; the page stays put.
+ assert.equal(fakeWindow.location.href, 'http://localhost/after-login')
+ } finally {
+ delete (global as any).window
+ delete (global as any).navigator
+ }
+ })
+})
diff --git a/packages/wallet-plugin-tacklebox/test/tsconfig.json b/packages/wallet-plugin-tacklebox/test/tsconfig.json
new file mode 100644
index 00000000..5e3252bd
--- /dev/null
+++ b/packages/wallet-plugin-tacklebox/test/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": ["../tsconfig.json", "../../../tsconfig.test.json"],
+ "compilerOptions": {
+ "baseUrl": "..",
+ "paths": {
+ "$lib": ["src"],
+ "$lib/*": ["src/*"],
+ "$test": ["test"],
+ "$test/*": ["test/*"]
+ }
+ },
+ "include": ["*.ts", "**/*.ts", "../src/**/*.ts"]
+}
diff --git a/packages/wallet-plugin-tacklebox/test/utils/mock-esr.ts b/packages/wallet-plugin-tacklebox/test/utils/mock-esr.ts
new file mode 100644
index 00000000..1e62ea74
--- /dev/null
+++ b/packages/wallet-plugin-tacklebox/test/utils/mock-esr.ts
@@ -0,0 +1,88 @@
+import * as zlib from 'pako'
+import {APIClient, FetchProvider} from '@wharfkit/antelope'
+import {
+ ABICache,
+ CallbackPayload,
+ PermissionLevel,
+ PrivateKey,
+ SigningRequest,
+} from '@wharfkit/session'
+import {
+ mockChainId,
+ mockFetch,
+ mockPermissionLevel,
+ mockPrivateKey,
+ mockUrl,
+} from '@wharfkit/mock-data'
+
+export const mockPublicKey = String(PrivateKey.from(mockPrivateKey).toPublic())
+export const mockChannelId = '00000000-0000-0000-0000-00000000cafe'
+export const mockChannelUrl = `https://cb.anchor.link/${mockChannelId}`
+
+const client = new APIClient({provider: new FetchProvider(mockUrl, {fetch: mockFetch})})
+const abiCache = new ABICache(client)
+
+const mockTapos = {
+ expiration: '2035-01-01T00:00:00',
+ ref_block_num: 0,
+ ref_block_prefix: 0,
+}
+
+function signAndBuildPayload(resolved): CallbackPayload {
+ const signature = PrivateKey.from(mockPrivateKey).signDigest(
+ resolved.transaction.signingDigest(mockChainId)
+ )
+ const callback = resolved.getCallback([signature])
+ if (!callback) {
+ throw new Error('mock request has no callback')
+ }
+ return {...callback.payload}
+}
+
+/**
+ * Build the callback payload TackleBox POSTs after the user accepts a login
+ * request: the signed identity plus the link session announcement.
+ */
+export function makeLoginCallbackPayload(options: {channel?: boolean} = {}): CallbackPayload {
+ const request = SigningRequest.identity(
+ {
+ callback: {url: 'https://cb.anchor.link/login-channel', background: true},
+ scope: 'unittest',
+ chainId: mockChainId,
+ },
+ {zlib}
+ )
+ const resolved = request.resolve(
+ new Map(),
+ PermissionLevel.from(mockPermissionLevel),
+ mockTapos
+ )
+ const payload = signAndBuildPayload(resolved)
+ if (options.channel !== false) {
+ payload.link_ch = mockChannelUrl
+ payload.link_key = mockPublicKey
+ payload.link_name = 'TackleBox'
+ }
+ return payload
+}
+
+/**
+ * Build the callback payload TackleBox POSTs after signing a pushed
+ * transaction request.
+ */
+export async function makeTransactCallbackPayload(action): Promise {
+ const request = await SigningRequest.create(
+ {
+ action,
+ chainId: mockChainId,
+ broadcast: false,
+ callback: {url: 'https://cb.anchor.link/transact-channel', background: true},
+ },
+ {zlib, abiProvider: abiCache}
+ )
+ const abis = await request.fetchAbis(abiCache)
+ const resolved = request.resolve(abis, PermissionLevel.from(mockPermissionLevel), mockTapos)
+ const payload = signAndBuildPayload(resolved)
+ payload.link_name = 'TackleBox'
+ return payload
+}
diff --git a/packages/wallet-plugin-tacklebox/test/utils/mock-ui.ts b/packages/wallet-plugin-tacklebox/test/utils/mock-ui.ts
new file mode 100644
index 00000000..0b9e6adb
--- /dev/null
+++ b/packages/wallet-plugin-tacklebox/test/utils/mock-ui.ts
@@ -0,0 +1,88 @@
+import {
+ Cancelable,
+ PromptArgs,
+ PromptElement,
+ PromptResponse,
+ UserInterface,
+} from '@wharfkit/session'
+
+export interface RecordingUserInterface extends UserInterface {
+ prompts: PromptArgs[]
+ statuses: string[]
+ lastPrompt(): PromptArgs | undefined
+ clickButton(index: number): void
+ close(reason?: string): void
+}
+
+/** A UserInterface that records every prompt and lets tests drive buttons. */
+export function makeMockUI(): RecordingUserInterface {
+ const prompts: PromptArgs[] = []
+ const statuses: string[] = []
+ let rejectCurrent: ((reason: Error) => void) | undefined
+
+ const ui: any = {
+ prompts,
+ statuses,
+ lastPrompt() {
+ return prompts[prompts.length - 1]
+ },
+ clickButton(index: number) {
+ const args = ui.lastPrompt()
+ if (!args) {
+ throw new Error('No prompt has been shown')
+ }
+ const buttons = (args.elements as PromptElement[]).filter((e) => e.type === 'button')
+ const button: any = buttons[index]
+ if (!button) {
+ throw new Error(`No button at index ${index} (found ${buttons.length})`)
+ }
+ button.data.onClick()
+ },
+ close(reason = 'Modal closed') {
+ if (rejectCurrent) {
+ rejectCurrent(new Error(reason))
+ rejectCurrent = undefined
+ }
+ },
+ status(message: string) {
+ statuses.push(message)
+ return Promise.resolve()
+ },
+ prompt(args: PromptArgs): Cancelable {
+ prompts.push(args)
+ const promise = new Promise((resolve, reject) => {
+ rejectCurrent = reject
+ })
+ // Swallow the default unhandled rejection; tests assert on the caller's handling.
+ promise.catch(() => undefined)
+ return Object.assign(promise, {
+ cancel: (reason?: string) => {
+ ui.close(reason)
+ return promise as Cancelable
+ },
+ }) as Cancelable
+ },
+ getTranslate() {
+ return (key: string, options?: Record) =>
+ (options && options.default) || key
+ },
+ translate(key: string) {
+ return key
+ },
+ addTranslations() {
+ // Translations are not rendered by the mock.
+ },
+ onLogin: () => Promise.resolve(),
+ onLoginComplete: () => Promise.resolve(),
+ onTransact: () => Promise.resolve(),
+ onTransactComplete: () => Promise.resolve(),
+ onSign: () => Promise.resolve(),
+ onSignComplete: () => Promise.resolve(),
+ onBroadcast: () => Promise.resolve(),
+ onBroadcastComplete: () => Promise.resolve(),
+ onAccountCreate: () => Promise.resolve(),
+ onAccountCreateComplete: () => Promise.resolve(),
+ onError: () => Promise.resolve(),
+ }
+ return ui as RecordingUserInterface
+}
diff --git a/packages/wallet-plugin-tacklebox/tsconfig.json b/packages/wallet-plugin-tacklebox/tsconfig.json
new file mode 100644
index 00000000..e4f39799
--- /dev/null
+++ b/packages/wallet-plugin-tacklebox/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "resolveJsonModule": true
+ },
+ "include": [
+ "src/**/*"
+ ]
+}
diff --git a/packages/wallet-plugin-tokenpocket/package.json b/packages/wallet-plugin-tokenpocket/package.json
index 0b6ca150..f3523d06 100644
--- a/packages/wallet-plugin-tokenpocket/package.json
+++ b/packages/wallet-plugin-tokenpocket/package.json
@@ -1,7 +1,7 @@
{
"name": "@wharfkit/wallet-plugin-tokenpocket",
"description": "A WalletPlugin for the TokenPocket wallet for use within the @wharfkit/session.",
- "version": "4.0.0-rc7",
+ "version": "4.0.0",
"homepage": "https://wharfkit.com/plugins/wallet-plugin-tokenpocket",
"license": "BSD-3-Clause",
"engines": {
diff --git a/packages/wallet-plugin-web-authenticator/package.json b/packages/wallet-plugin-web-authenticator/package.json
index 6793393f..7c2b98a5 100644
--- a/packages/wallet-plugin-web-authenticator/package.json
+++ b/packages/wallet-plugin-web-authenticator/package.json
@@ -1,7 +1,7 @@
{
"name": "@wharfkit/wallet-plugin-web-authenticator",
"description": "A Web Authenticator wallet plugin for use with @wharfkit/session.",
- "version": "4.0.0-rc7",
+ "version": "4.0.0",
"homepage": "https://wharfkit.com/plugins/wallet-plugin-web-authenticator",
"license": "BSD-3-Clause",
"engines": {
diff --git a/packages/web-renderer/package.json b/packages/web-renderer/package.json
index 8a373c52..f283f401 100644
--- a/packages/web-renderer/package.json
+++ b/packages/web-renderer/package.json
@@ -1,6 +1,6 @@
{
"name": "@wharfkit/web-renderer",
- "version": "4.0.0-rc7",
+ "version": "4.0.0",
"description": "",
"license": "BSD-3-Clause",
"engines": {
diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json
index 598ebce6..3d5f2f92 100644
--- a/packages/web-ui/package.json
+++ b/packages/web-ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@wharfkit/web-ui",
- "version": "4.0.0-rc7",
+ "version": "4.0.0",
"homepage": "https://wharfkit.com",
"description": "Modern embedded UI renderer for WharfKit SessionKit",
"type": "module",
diff --git a/packages/webauthn/package.json b/packages/webauthn/package.json
index 9f4228fd..38d29700 100644
--- a/packages/webauthn/package.json
+++ b/packages/webauthn/package.json
@@ -1,7 +1,7 @@
{
"name": "@wharfkit/webauthn",
"description": "WebAuthn helpers for antelope core",
- "version": "4.0.0-rc7",
+ "version": "4.0.0",
"homepage": "https://wharfkit.com",
"license": "BSD-3-Clause",
"engines": {
diff --git a/rolldown.config.mjs b/rolldown.config.mjs
index 56308949..3e3fb874 100644
--- a/rolldown.config.mjs
+++ b/rolldown.config.mjs
@@ -39,6 +39,7 @@ const MEMBERS = {
},
},
'wallet-plugin-anchor': {stripInternal: true, replaceVersion: true},
+ 'wallet-plugin-tacklebox': {stripInternal: true},
'wallet-plugin-scatter': {browser: true, bundleDeps: true, dir: true},
'wallet-plugin-tokenpocket': {browser: true, bundleDeps: true, dir: true},
webauthn: {
diff --git a/scripts/release.ts b/scripts/release.ts
index a2beb564..384e7c54 100644
--- a/scripts/release.ts
+++ b/scripts/release.ts
@@ -1,7 +1,7 @@
import {execFileSync} from 'node:child_process'
import {existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync} from 'node:fs'
import {tmpdir} from 'node:os'
-import {join} from 'node:path'
+import {basename, join} from 'node:path'
import semver from 'semver'
import {checkDependencyLicenses, checkMemberLicenses} from './check-licenses.ts'
@@ -194,6 +194,62 @@ function distTag(version: string): string {
return semver.prerelease(version) ? 'next' : 'latest'
}
+// files outside packages/ whose changes alter every published tarball
+const SHARED_BUILD_FILES = [
+ 'bun.lock',
+ 'common.mk',
+ 'rolldown.base.mjs',
+ 'rolldown.config.mjs',
+ 'scripts/release.ts',
+ 'tsconfig.base.json',
+]
+
+// manifest fields whose changes alter nothing a consumer installs
+const METADATA_KEYS = ['author', 'bugs', 'description', 'homepage', 'keywords', 'repository']
+
+function manifestChangeIsMetadata(base: string, file: string): boolean {
+ const before = trySh('git', ['show', `${base}:${file}`])
+ if (before === null) return false
+ const a = JSON.parse(before)
+ const b = JSON.parse(readFileSync(join(ROOT, file), 'utf8'))
+ for (const key of METADATA_KEYS) {
+ delete a[key]
+ delete b[key]
+ }
+ return JSON.stringify(a) === JSON.stringify(b)
+}
+
+function promotionBody(list: Member[], base: string): string {
+ const files = sh('git', ['diff', '--name-only', `${base}..HEAD`])
+ .split('\n')
+ .filter(Boolean)
+ const names = new Set(publishable(list).map((m) => basename(m.dir)))
+ const changed = new Set()
+ const metadataOnly = new Set()
+ for (const file of files) {
+ const match = file.match(/^packages\/([^/]+)\/(.+)$/)
+ if (!match || !names.has(match[1])) continue
+ const metadata =
+ match[2] === 'README.md' ||
+ (match[2] === 'package.json' && manifestChangeIsMetadata(base, file))
+ if (metadata) metadataOnly.add(match[1])
+ else changed.add(match[1])
+ }
+ for (const name of changed) metadataOnly.delete(name)
+ const lines: string[] = []
+ if (changed.size) {
+ const others = metadataOnly.size ? `, and ${metadataOnly.size} others by metadata only` : ''
+ lines.push(`Packages changed: ${[...changed].sort().join(', ')}${others}.`)
+ } else if (metadataOnly.size) {
+ lines.push(`Packages changed by metadata only: ${[...metadataOnly].sort().join(', ')}.`)
+ } else {
+ lines.push('No package changed.')
+ }
+ const shared = SHARED_BUILD_FILES.filter((f) => files.includes(f))
+ if (shared.length) lines.push(`Shared build changes: ${shared.join(', ')}.`)
+ return lines.join('\n')
+}
+
function resolveTarget(current: string, arg: string): string {
if (semver.valid(arg)) return arg
if (arg === 'prerelease') {
@@ -299,6 +355,9 @@ function bump(arg: string, dryRun: boolean) {
verify({install: true})
+ sh('git', ['fetch', 'origin', 'master'])
+ const body = promotionBody(list, 'origin/master')
+
log(`bumping to ${target}`)
writeVersion(join(ROOT, 'package.json'), target)
for (const member of list) writeVersion(member.manifestPath, target)
@@ -308,6 +367,7 @@ function bump(arg: string, dryRun: boolean) {
if (dryRun) {
sh('git', ['checkout', '--', '.'])
+ log(`promotion PR body would read:\n${body}`)
log(`dry run for ${target} complete; changes reverted`)
return
}
@@ -315,7 +375,7 @@ function bump(arg: string, dryRun: boolean) {
sh('git', ['add', '-A'])
sh('git', ['commit', '-m', `Version ${target}`])
sh('git', ['push', 'origin', 'dev'])
- sh('gh', [
+ const url = sh('gh', [
'pr',
'create',
'--base',
@@ -325,9 +385,9 @@ function bump(arg: string, dryRun: boolean) {
'--title',
`Version ${target}`,
'--body',
- `Lockstep release ${target}. Promotes dev to master; publishes on merge via release.yml.`,
+ body,
])
- log(`promotion PR opened for ${target}`)
+ log(`promotion PR opened for ${target}: ${url}`)
}
function publish(force: boolean) {
@@ -393,7 +453,14 @@ function publish(force: boolean) {
'--title',
`Version ${version}`,
]
- if (semver.prerelease(version)) releaseArgs.push('--prerelease')
+ if (semver.prerelease(version)) {
+ releaseArgs.push('--prerelease')
+ } else {
+ releaseArgs.push(
+ '--notes',
+ `Release notes: https://wharfkit.com/docs/releases/${version.replace(/\./g, '-')}`
+ )
+ }
if (trySh('gh', ['release', 'view', tag]) !== null) {
log(`GitHub release for ${tag} already exists`)
} else {