GMGN Fee Unlock

GMGN解除sol链gas最低0.0001的限制,悬浮图标常驻,点击打开/关闭面板

// ==UserScript==
// @name         GMGN Fee Unlock
// @namespace    https://greasyfork.org/zh-CN/scripts/547048-gmgn-fee-unlock
// @version      1.0.2
// @description  GMGN解除sol链gas最低0.0001的限制,悬浮图标常驻,点击打开/关闭面板
// @match        https://gmgn.ai/*
// @run-at       document-idle
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const STORAGE_KEY = 'GMGN_FEE_SETTINGS';
    const DRAG_TAB_KEY = 'GMGN_FEE_TAB_POS';
    const DRAG_PANEL_KEY = 'GMGN_FEE_PANEL_POS';

    const defaultState = {
        enabled: true,
        feePriority: '0.00000001',
        feeBribe: '0.00000001',
        hidden: false,
        dragLocked: true
    };

    const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
    const state = { ...defaultState, ...stored };

    const log = (...a) => console.log('%c[GMGN-FEE]', 'color:#7ad39a;font-weight:700', ...a);
    function saveState() { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); }
    function normalizeText(s) { return (s || '').replace(/\s+/g, ' ').toLowerCase(); }

    // —— 新增:判断是否是我们自己面板的控件 —— //
    function isOurPanelInput(input) {
        if (!input) return false;
        if (input.id === 'fee-priority' || input.id === 'fee-bribe') return true;
        if (input.closest && input.closest('#gmgn-fee-panel')) return true;
        if (input.closest && input.closest('#fee-tab')) return true;
        return false;
    }

    function detectFeeType(input) {
        // 根据附近文本区分优先费 / 贿赂费(只用于页面上的真实输入框)
        let cur = input;
        for (let i = 0; i < 6 && cur; i++, cur = cur.parentElement) {
            const txt = normalizeText(cur.textContent || '');
            if (!txt) continue;
            if (txt.includes('priority') || txt.includes('优先')) return 'priority';
            if (txt.includes('bribe') || txt.includes('贿赂')) return 'bribe';
        }
        return null;
    }

    function getWantFor(input) {
        const type = detectFeeType(input);
        if (type === 'priority') return state.feePriority;
        if (type === 'bribe') return state.feeBribe;
        return state.feePriority; // 默认走优先费
    }

    const patched = new WeakSet();
    function patchFeeInput(input) {
        if (!input || patched.has(input)) return;

        // —— 关键修复:忽略我们自己的面板输入框 —— //
        if (isOurPanelInput(input)) return;

        const type = detectFeeType(input);
        if (!type) return;

        try {
            input.removeAttribute('min');
            input.setAttribute('step', 'any');
            input.setAttribute('inputmode', 'decimal');
            input.setAttribute('pattern', '[0-9]*(\\.[0-9]+)?');
            input.setAttribute('aria-valuemin', '0');
            input.closest('.chakra-numberinput')?.setAttribute('data-tm-fee', '1');

            function enforce() {
                if (!state.enabled) return;
                try {
                    const want = getWantFor(input);
                    const now = input.value || input.getAttribute('aria-valuenow') || '';
                    const nowN = parseFloat(String(now || '0'));
                    const wantN = parseFloat(String(want));
                    if (isNaN(nowN) || nowN === 0 || (nowN >= 0.0001 && wantN < 0.0001) || nowN !== wantN) {
                        const desc = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
                        if (desc && desc.set) { try { desc.set.call(input, want); } catch (e) {} }
                        else input.value = want;
                        input.setAttribute('aria-valuenow', want);
                        input.setAttribute('aria-valuetext', want);
                        input.dispatchEvent(new Event('input', { bubbles: true }));
                        input.dispatchEvent(new Event('change', { bubbles: true }));
                        const wrap = input.closest('.chakra-numberinput');
                        if (wrap) wrap.setAttribute('value', want);
                        log(`enforce ${type} →`, want);
                    }
                } catch (e) {}
            }

            const handler = () => { if (!state.enabled) return setTimeout(enforce, 8); };
            input.addEventListener('input', handler, true);
            input.addEventListener('change', handler, true);
            input.addEventListener('blur', handler, true);

            const attrObs = new MutationObserver(() => { if (state.enabled) setTimeout(enforce, 6); });
            attrObs.observe(input, { attributes: true, attributeFilter: ['aria-valuemin', 'value', 'aria-valuenow', 'aria-valuetext'] });

            enforce();
            patched.add(input);
            input.__feeFix__attrObserver = attrObs;
        } catch (e) { console.warn('[GMGN-FEE] patch error', e); }
    }

    function scanAndPatch(root = document) {
        try {
            const inputs = Array.from((root || document).querySelectorAll('input'));
            for (const inp of inputs) {
                if (isOurPanelInput(inp)) continue; // —— 关键修复:扫描阶段也忽略面板输入 —— //
                patchFeeInput(inp);
            }
        } catch (e) { console.warn('[GMGN-FEE] scan error', e); }
    }

    function autoApply(root = document) { scanAndPatch(root); }

    const mo = new MutationObserver(muts => {
        if (!state.enabled) return;
        for (const m of muts) {
            if (m.type === 'childList' && m.addedNodes.length) {
                for (const n of m.addedNodes) if (n.nodeType === 1) autoApply(n);
            } else if (m.type === 'attributes' && m.target instanceof HTMLInputElement) {
                if (isOurPanelInput(m.target)) continue; // —— 关键修复:忽略面板输入 —— //
                patchFeeInput(m.target);
            }
        }
    });

    function isInteractiveElement(el) {
        if (!el) return false;
        const tag = el.tagName && el.tagName.toLowerCase();
        if (!tag) return false;
        if (['input', 'button', 'select', 'textarea', 'label', 'a'].includes(tag)) return true;
        if (el.closest && el.closest('input,button,select,textarea,a,label')) return true;
        return false;
    }

    function mountPanel() {
        if (document.getElementById('gmgn-fee-panel')) return;

        const wrap = document.createElement('div');
        wrap.id = 'gmgn-fee-panel';
        Object.assign(wrap.style, {
            position: 'fixed',
            right: '14px',
            bottom: '80px',
            zIndex: 99999999,
            background: '#0f1115',
            color: '#e6f3ea',
            padding: '10px',
            borderRadius: '10px',
            border: '1px solid rgba(255,255,255,0.04)',
            fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, Arial',
            fontSize: '13px',
            width: '15%',
            minWidth: '165px',
            maxWidth: '240px',
            boxShadow: '0 6px 18px rgba(0,0,0,.4)',
            display: state.hidden ? 'none' : 'block',
            transform: 'none'
        });

        wrap.innerHTML = `
            <div style="font-weight:700;margin-bottom:8px;">Fee Unlock</div>
            <div style="display:flex;align-items:center;gap:16px;margin-bottom:8px;">
                <label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
                    <input id="fee-enabled" type="checkbox" ${state.enabled ? 'checked' : ''}>
                    <span style="font-size:12px">自动保持(防回弹)</span>
                </label>
                <label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
                    <input id="drag-locked" type="checkbox" ${state.dragLocked ? 'checked' : ''}>
                    <span style="font-size:12px">拖动锁定</span>
                </label>
            </div>

            <div style="display:grid;grid-template-columns:64px 1fr;gap:6px;align-items:center;margin-bottom:8px;">
                <div style="font-size:12px">优先费</div>
                <input id="fee-priority" value="${state.feePriority}" style="padding:6px;border-radius:6px;background:#151821;border:1px solid #333;color:#e6f3ea;width:100%;box-sizing:border-box;">
                <div style="font-size:12px">贿赂费</div>
                <input id="fee-bribe" value="${state.feeBribe}" style="padding:6px;border-radius:6px;background:#151821;border:1px solid #333;color:#e6f3ea;width:100%;box-sizing:border-box;">
            </div>

            <div style="display:flex;gap:8px;">
                <button id="fee-apply" style="flex:1;padding:8px;border-radius:8px;background:#81D69D;color:white;border:none;cursor:pointer">立即应用</button>
                <button id="fee-hide" style="padding:8px;border-radius:8px;background:#242630;color:#e6f3ea;border:none;cursor:pointer">隐藏</button>
            </div>
        `;

        // 悬浮 tab
        const tab = document.createElement('div');
        tab.id = 'fee-tab';
        tab.textContent = 'Fee Unlock';
        Object.assign(tab.style, {
            position: 'fixed',
            zIndex: 99999999,
            background: '#21302D',
            color: '#81D69D',
            padding: '3.25px 5px',
            borderRadius: '6px',
            fontSize: '12px',
            cursor: 'pointer',
            display: 'block'
        });

        const savedTabPos = JSON.parse(localStorage.getItem(DRAG_TAB_KEY) || '{}');
        if (savedTabPos.right) tab.style.right = savedTabPos.right;
        else tab.style.right = '20px';
        if (savedTabPos.bottom) tab.style.bottom = savedTabPos.bottom;
        else tab.style.bottom = '20px';

        // load saved panel ratio position
        const savedPanelPos = JSON.parse(localStorage.getItem(DRAG_PANEL_KEY) || '{}');
        if (savedPanelPos.ratioLeft && savedPanelPos.ratioTop) {
            wrap.dataset.ratioLeft = savedPanelPos.ratioLeft;
            wrap.dataset.ratioTop = savedPanelPos.ratioTop;
            wrap.style.left = (window.innerWidth * parseFloat(wrap.dataset.ratioLeft)) + 'px';
            wrap.style.top = (window.innerHeight * parseFloat(wrap.dataset.ratioTop)) + 'px';
            wrap.style.right = 'auto';
            wrap.style.bottom = 'auto';
        } else {
            wrap.style.right = '14px';
            wrap.style.bottom = '80px';
            wrap.style.left = '';
            wrap.style.top = '';
        }

        document.body.appendChild(wrap);
        document.body.appendChild(tab);

        const elEnabled = wrap.querySelector('#fee-enabled');
        const elPriority = wrap.querySelector('#fee-priority');
        const elBribe = wrap.querySelector('#fee-bribe');
        const elApply = wrap.querySelector('#fee-apply');
        const elHide = wrap.querySelector('#fee-hide');
        const elDragLocked = wrap.querySelector('#drag-locked');

        // small CSS for checkbox visuals
        const style = document.createElement('style');
        style.innerHTML = `input[type=checkbox] { accent-color: #ffffff; width: 14px; height: 14px; }`;
        document.head.appendChild(style);

        elEnabled.addEventListener('change', e => { state.enabled = !!e.target.checked; saveState(); if (state.enabled) autoApply(); });
        // 单独保存,不相互覆盖
        elPriority.addEventListener('input', e => { state.feePriority = e.target.value; saveState(); });
        elBribe.addEventListener('input', e => { state.feeBribe = e.target.value; saveState(); });
        elApply.addEventListener('click', () => autoApply());

        elHide.addEventListener('click', () => {
            wrap.style.display = 'none';
            tab.style.display = 'block';
            state.hidden = true;
            saveState();
        });

        tab.addEventListener('click', (ev) => {
            // If tab was just dragged, tabMoved logic will prevent toggle (implemented below)
            if (tab._moved) { tab._moved = false; ev.stopImmediatePropagation(); return; }
            wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none';
            state.hidden = wrap.style.display === 'none';
            saveState();
        });

        elDragLocked.addEventListener('change', e => { state.dragLocked = !!e.target.checked; saveState(); });

        // ---------- Dragging logic ----------
        let dragPanel = false, oxPanel = 0, oyPanel = 0;
        let panelMoved = false;
        let startX = 0, startY = 0;

        function isInteractiveElement(el) {
            if (!el) return false;
            const tag = el.tagName && el.tagName.toLowerCase();
            if (!tag) return false;
            if (['input', 'button', 'select', 'textarea', 'label', 'a'].includes(tag)) return true;
            if (el.closest && el.closest('input,button,select,textarea,a,label')) return true;
            return false;
        }

        wrap.addEventListener('mousedown', (ev) => {
            if (state.dragLocked) return;
            if (isInteractiveElement(ev.target)) return;

            const rect = wrap.getBoundingClientRect();
            if (!wrap.style.left || wrap.style.left === '') {
                wrap.style.left = rect.left + 'px';
            }
            if (!wrap.style.top || wrap.style.top === '') {
                wrap.style.top = rect.top + 'px';
            }
            wrap.style.right = 'auto';
            wrap.style.bottom = 'auto';

            dragPanel = true;
            oxPanel = ev.clientX - parseFloat(wrap.style.left || rect.left);
            oyPanel = ev.clientY - parseFloat(wrap.style.top || rect.top);
            panelMoved = false;
            startX = ev.clientX;
            startY = ev.clientY;
            wrap.style.cursor = 'grabbing';
            ev.preventDefault();
        });

        function onDocumentMouseMoveForPanel(ev) {
            if (!dragPanel) return;
            const newLeft = ev.clientX - oxPanel;
            const newTop = ev.clientY - oyPanel;
            const clampedLeft = Math.max(0, Math.min(window.innerWidth - wrap.offsetWidth, newLeft));
            const clampedTop = Math.max(0, Math.min(window.innerHeight - wrap.offsetHeight, newTop));
            wrap.style.left = clampedLeft + 'px';
            wrap.style.top = clampedTop + 'px';
            wrap.style.right = 'auto';
            wrap.style.bottom = 'auto';
            if (!panelMoved && (Math.abs(ev.clientX - startX) > 3 || Math.abs(ev.clientY - startY) > 3)) panelMoved = true;
        }

        function onDocumentMouseUpForPanel() {
            if (!dragPanel) return;
            dragPanel = false;
            wrap.style.cursor = 'default';
            const rect = wrap.getBoundingClientRect();
            wrap.dataset.ratioLeft = (rect.left / window.innerWidth).toString();
            wrap.dataset.ratioTop = (rect.top / window.innerHeight).toString();
            localStorage.setItem(DRAG_PANEL_KEY, JSON.stringify({ ratioLeft: wrap.dataset.ratioLeft, ratioTop: wrap.dataset.ratioTop }));
        }

        document.addEventListener('mousemove', onDocumentMouseMoveForPanel);
        document.addEventListener('mouseup', onDocumentMouseUpForPanel);

        wrap.addEventListener('click', (ev) => {
            if (panelMoved) {
                ev.stopImmediatePropagation();
                ev.preventDefault();
                panelMoved = false;
            }
        }, true);

        // ---------- Tab dragging ----------
        let dragTab = false, oxTab = 0, oyTab = 0, tabMoved = false;

        tab.addEventListener('mousedown', (ev) => {
            if (state.dragLocked) return;
            dragTab = true;
            oxTab = ev.clientX - (tab.getBoundingClientRect().left);
            oyTab = ev.clientY - (tab.getBoundingClientRect().top);
            tab.style.cursor = 'grabbing';
            tabMoved = false;
            tab._startX = ev.clientX;
            tab._startY = ev.clientY;
            ev.preventDefault();
        });

        function onDocumentMouseMoveForTab(ev) {
            if (!dragTab) return;
            const newLeft = ev.clientX - oxTab;
            const newTop = ev.clientY - oyTab;
            const right = Math.max(0, Math.min(window.innerWidth - tab.offsetWidth, window.innerWidth - (newLeft + tab.offsetWidth)));
            const bottom = Math.max(0, Math.min(window.innerHeight - tab.offsetHeight, window.innerHeight - (newTop + tab.offsetHeight)));
            tab.style.right = right + 'px';
            tab.style.bottom = bottom + 'px';
            tab.style.left = 'auto';
            tab.style.top = 'auto';

            if (!tabMoved && (Math.abs(ev.clientX - tab._startX) > 3 || Math.abs(ev.clientY - tab._startY) > 3)) tabMoved = true;
            if (tabMoved) tab._moved = true;
        }

        function onDocumentMouseUpForTab() {
            if (!dragTab) return;
            dragTab = false;
            tab.style.cursor = 'pointer';
            localStorage.setItem(DRAG_TAB_KEY, JSON.stringify({ right: tab.style.right, bottom: tab.style.bottom }));
            setTimeout(() => { tab._moved = false; }, 300);
        }

        document.addEventListener('mousemove', onDocumentMouseMoveForTab);
        document.addEventListener('mouseup', onDocumentMouseUpForTab);

        tab.addEventListener('click', function (ev) {
            if (tabMoved || this._moved) {
                ev.stopImmediatePropagation();
                ev.preventDefault();
                tabMoved = false;
                this._moved = false;
            }
        }, true);

        function initPanelPosition() {
            const p = JSON.parse(localStorage.getItem(DRAG_PANEL_KEY) || '{}');
            if (p.ratioLeft && p.ratioTop) {
                const left = Math.round(window.innerWidth * parseFloat(p.ratioLeft));
                const top = Math.round(window.innerHeight * parseFloat(p.ratioTop));
                wrap.style.left = left + 'px';
                wrap.style.top = top + 'px';
                wrap.style.right = 'auto';
                wrap.style.bottom = 'auto';
                wrap.dataset.ratioLeft = p.ratioLeft;
                wrap.dataset.ratioTop = p.ratioTop;
            } else {
                wrap.style.left = '';
                wrap.style.top = '';
                wrap.style.right = '14px';
                wrap.style.bottom = '80px';
                delete wrap.dataset.ratioLeft;
                delete wrap.dataset.ratioTop;
            }
        }

        initPanelPosition();
        window.addEventListener('resize', () => {
            if (wrap.dataset.ratioLeft && wrap.dataset.ratioTop) {
                wrap.style.left = Math.round(window.innerWidth * parseFloat(wrap.dataset.ratioLeft)) + 'px';
                wrap.style.top = Math.round(window.innerHeight * parseFloat(wrap.dataset.ratioTop)) + 'px';
                wrap.style.right = 'auto';
                wrap.style.bottom = 'auto';
            }
        });

        requestAnimationFrame(() => {
            wrap.style.willChange = 'opacity';
            void wrap.getBoundingClientRect();
            wrap.style.willChange = '';
        });
    }

    function start() {
        setTimeout(() => {
            try { mountPanel(); } catch (e) { console.warn(e); }
            autoApply(document);
            try {
                mo.observe(document.documentElement, {
                    childList: true,
                    subtree: true,
                    attributes: true,
                    attributeFilter: ['aria-valuemin', 'aria-valuenow', 'value', 'min']
                });
            } catch (e) { console.warn('[GMGN-FEE] mo start error', e); }
            setInterval(() => { if (state.enabled) scanAndPatch(document); }, 1000);
        }, 2000);
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
    else start();

})();