diff --git a/src/__tests__/nativeScriptUtils.test.ts b/src/__tests__/nativeScriptUtils.test.ts new file mode 100644 index 0000000..9c8d834 --- /dev/null +++ b/src/__tests__/nativeScriptUtils.test.ts @@ -0,0 +1,415 @@ +import { describe, it, expect } from "@jest/globals"; +import { serializeNativeScript, type NativeScript } from "@meshsdk/core"; +import { + type DecodedNativeScript, + decodeNativeScriptFromCbor, + decodedToNativeScript, + collectSigKeyHashes, + isHierarchicalScript, + computeRequiredSigners, + detectTypeFromSigParents, + normalizeCborHex, + countTotalSigs, + getScriptDepth, +} from "../utils/nativeScriptUtils"; + +// --- Test fixtures --- + +const sigA: DecodedNativeScript = { type: "sig", keyHash: "aaaa" }; +const sigB: DecodedNativeScript = { type: "sig", keyHash: "bbbb" }; +const sigC: DecodedNativeScript = { type: "sig", keyHash: "cccc" }; + +const flatAll: DecodedNativeScript = { + type: "all", + scripts: [sigA, sigB, sigC], +}; + +const flatAny: DecodedNativeScript = { + type: "any", + scripts: [sigA, sigB], +}; + +const flatAtLeast: DecodedNativeScript = { + type: "atLeast", + required: 2, + scripts: [sigA, sigB, sigC], +}; + +// Hierarchical: all(atLeast(2, [sigA, sigB, sigC])) +const hierarchical: DecodedNativeScript = { + type: "all", + scripts: [ + { + type: "atLeast", + required: 2, + scripts: [sigA, sigB, sigC], + }, + ], +}; + +// Deeply nested: all(any(all(sigA, sigB))) +const deeplyNested: DecodedNativeScript = { + type: "all", + scripts: [ + { + type: "any", + scripts: [ + { + type: "all", + scripts: [sigA, sigB], + }, + ], + }, + ], +}; + +const withTimelock: DecodedNativeScript = { + type: "all", + scripts: [ + sigA, + { type: "timelockStart", slot: "100" }, + { type: "timelockExpiry", slot: "999" }, + ], +}; + +// --- Tests --- + +describe("normalizeCborHex", () => { + it("should strip 0x prefix", () => { + expect(normalizeCborHex("0xabcdef")).toBe("abcdef"); + }); + + it("should strip 0X prefix (uppercase)", () => { + expect(normalizeCborHex("0Xabcdef")).toBe("abcdef"); + }); + + it("should trim whitespace", () => { + expect(normalizeCborHex(" abcdef ")).toBe("abcdef"); + }); + + it("should handle empty string", () => { + expect(normalizeCborHex("")).toBe(""); + }); + + it("should pass through valid hex", () => { + expect(normalizeCborHex("abcdef")).toBe("abcdef"); + }); +}); + +describe("collectSigKeyHashes", () => { + it("should collect from a single sig", () => { + expect(collectSigKeyHashes(sigA)).toEqual(["aaaa"]); + }); + + it("should collect from flat all", () => { + const hashes = collectSigKeyHashes(flatAll); + expect(hashes).toEqual(["aaaa", "bbbb", "cccc"]); + }); + + it("should collect from hierarchical script", () => { + const hashes = collectSigKeyHashes(hierarchical); + expect(hashes).toEqual(["aaaa", "bbbb", "cccc"]); + }); + + it("should collect from deeply nested script", () => { + const hashes = collectSigKeyHashes(deeplyNested); + expect(hashes).toEqual(["aaaa", "bbbb"]); + }); + + it("should deduplicate repeated hashes", () => { + const dup: DecodedNativeScript = { + type: "all", + scripts: [sigA, sigA, sigB], + }; + const hashes = collectSigKeyHashes(dup); + expect(hashes).toEqual(["aaaa", "bbbb"]); + }); + + it("should return empty for timelock nodes", () => { + expect(collectSigKeyHashes({ type: "timelockStart", slot: "0" })).toEqual( + [], + ); + }); + + it("should skip timelocks but collect sigs in mixed scripts", () => { + const hashes = collectSigKeyHashes(withTimelock); + expect(hashes).toEqual(["aaaa"]); + }); +}); + +describe("isHierarchicalScript", () => { + it("should return false for flat all", () => { + expect(isHierarchicalScript(flatAll)).toBe(false); + }); + + it("should return false for flat any", () => { + expect(isHierarchicalScript(flatAny)).toBe(false); + }); + + it("should return false for flat atLeast", () => { + expect(isHierarchicalScript(flatAtLeast)).toBe(false); + }); + + it("should return true for all(atLeast(...))", () => { + expect(isHierarchicalScript(hierarchical)).toBe(true); + }); + + it("should return true for deeply nested scripts", () => { + expect(isHierarchicalScript(deeplyNested)).toBe(true); + }); + + it("should return false for a single sig", () => { + expect(isHierarchicalScript(sigA)).toBe(false); + }); +}); + +describe("computeRequiredSigners", () => { + it("should return 1 for a single sig", () => { + expect(computeRequiredSigners(sigA)).toBe(1); + }); + + it("should return count of all sigs for 'all'", () => { + expect(computeRequiredSigners(flatAll)).toBe(3); + }); + + it("should return 1 for 'any' with sigs", () => { + expect(computeRequiredSigners(flatAny)).toBe(1); + }); + + it("should return required count for flat atLeast", () => { + expect(computeRequiredSigners(flatAtLeast)).toBe(2); + }); + + it("should handle hierarchical: all(atLeast(2, [sig, sig, sig])) = 2", () => { + expect(computeRequiredSigners(hierarchical)).toBe(2); + }); + + it("should return 0 for timelockStart", () => { + expect(computeRequiredSigners({ type: "timelockStart", slot: "0" })).toBe( + 0, + ); + }); + + it("should return 0 for timelockExpiry", () => { + expect(computeRequiredSigners({ type: "timelockExpiry", slot: "0" })).toBe( + 0, + ); + }); + + it("should handle all with timelocks (only count sigs)", () => { + // all(sigA, timelockStart, timelockExpiry) -> need sigA = 1 + expect(computeRequiredSigners(withTimelock)).toBe(1); + }); + + it("should return 0 for empty any", () => { + expect(computeRequiredSigners({ type: "any", scripts: [] })).toBe(0); + }); +}); + +describe("detectTypeFromSigParents", () => { + it("should detect 'all' for flat all", () => { + expect(detectTypeFromSigParents(flatAll)).toBe("all"); + }); + + it("should detect 'any' for flat any", () => { + expect(detectTypeFromSigParents(flatAny)).toBe("any"); + }); + + it("should detect 'atLeast' for flat atLeast", () => { + expect(detectTypeFromSigParents(flatAtLeast)).toBe("atLeast"); + }); + + it("should detect 'atLeast' for all(atLeast(...))", () => { + // sigs' parent is atLeast, so atLeast wins + expect(detectTypeFromSigParents(hierarchical)).toBe("atLeast"); + }); + + it("should detect 'all' for deeply nested all(any(all(sig, sig)))", () => { + // sigs' parent is 'all' (innermost), but 'any' and 'all' both appear + // Priority: atLeast > all > any, so 'all' wins + expect(detectTypeFromSigParents(deeplyNested)).toBe("all"); + }); +}); + +describe("decodedToNativeScript", () => { + it("should convert sig node", () => { + expect(decodedToNativeScript(sigA)).toEqual({ + type: "sig", + keyHash: "aaaa", + }); + }); + + it("should convert flat all", () => { + const result = decodedToNativeScript(flatAll); + expect(result).toEqual({ + type: "all", + scripts: [ + { type: "sig", keyHash: "aaaa" }, + { type: "sig", keyHash: "bbbb" }, + { type: "sig", keyHash: "cccc" }, + ], + }); + }); + + it("should convert flat atLeast with required", () => { + const result = decodedToNativeScript(flatAtLeast); + expect(result).toEqual({ + type: "atLeast", + required: 2, + scripts: [ + { type: "sig", keyHash: "aaaa" }, + { type: "sig", keyHash: "bbbb" }, + { type: "sig", keyHash: "cccc" }, + ], + }); + }); + + it("should convert hierarchical script recursively", () => { + const result = decodedToNativeScript(hierarchical); + expect(result).toEqual({ + type: "all", + scripts: [ + { + type: "atLeast", + required: 2, + scripts: [ + { type: "sig", keyHash: "aaaa" }, + { type: "sig", keyHash: "bbbb" }, + { type: "sig", keyHash: "cccc" }, + ], + }, + ], + }); + }); + + it("should convert timelockStart to 'after'", () => { + const result = decodedToNativeScript({ + type: "timelockStart", + slot: "12345", + }); + expect(result).toEqual({ type: "after", slot: "12345" }); + }); + + it("should convert timelockExpiry to 'before'", () => { + const result = decodedToNativeScript({ + type: "timelockExpiry", + slot: "99999", + }); + expect(result).toEqual({ type: "before", slot: "99999" }); + }); + + it("should handle mixed script with timelocks", () => { + const result = decodedToNativeScript(withTimelock); + expect(result).toEqual({ + type: "all", + scripts: [ + { type: "sig", keyHash: "aaaa" }, + { type: "after", slot: "100" }, + { type: "before", slot: "999" }, + ], + }); + }); +}); + +describe("decodeNativeScriptFromCbor (integration)", () => { + it("should decode a serialized hierarchical script", () => { + const keyHashA = "11".repeat(28); + const keyHashB = "22".repeat(28); + const keyHashC = "33".repeat(28); + + const original: NativeScript = { + type: "all", + scripts: [ + { + type: "atLeast", + required: 2, + scripts: [ + { type: "sig", keyHash: keyHashA }, + { type: "sig", keyHash: keyHashB }, + { type: "sig", keyHash: keyHashC }, + ], + }, + ], + }; + + const serialized = serializeNativeScript(original, undefined, 0); + expect(serialized.scriptCbor).toBeDefined(); + const scriptCbor = serialized.scriptCbor!; + expect(typeof scriptCbor).toBe("string"); + expect(scriptCbor.length).toBeGreaterThan(0); + + const decoded = decodeNativeScriptFromCbor(scriptCbor); + expect(decoded.type).toBe("all"); + expect(isHierarchicalScript(decoded)).toBe(true); + + const hashes = collectSigKeyHashes(decoded); + expect(hashes).toEqual([ + keyHashA.toLowerCase(), + keyHashB.toLowerCase(), + keyHashC.toLowerCase(), + ]); + + const ns = decodedToNativeScript(decoded) as any; + expect(ns.type).toBe("all"); + expect(Array.isArray(ns.scripts)).toBe(true); + expect(ns.scripts).toHaveLength(1); + expect(ns.scripts[0].type).toBe("atLeast"); + expect(ns.scripts[0].required).toBe(2); + expect(ns.scripts[0].scripts).toHaveLength(3); + }); +}); + +describe("countTotalSigs", () => { + it("should count 1 for a single sig", () => { + expect(countTotalSigs(sigA)).toBe(1); + }); + + it("should count all sigs in flat script", () => { + expect(countTotalSigs(flatAll)).toBe(3); + }); + + it("should count sigs in hierarchical script", () => { + expect(countTotalSigs(hierarchical)).toBe(3); + }); + + it("should count sigs in deeply nested script", () => { + expect(countTotalSigs(deeplyNested)).toBe(2); + }); + + it("should return 0 for timelocks", () => { + expect(countTotalSigs({ type: "timelockStart", slot: "0" })).toBe(0); + }); + + it("should count sigs including duplicates", () => { + const dup: DecodedNativeScript = { + type: "all", + scripts: [sigA, sigA, sigB], + }; + // countTotalSigs counts leaves, not unique hashes + expect(countTotalSigs(dup)).toBe(3); + }); +}); + +describe("getScriptDepth", () => { + it("should return 0 for a sig node", () => { + expect(getScriptDepth(sigA)).toBe(0); + }); + + it("should return 1 for flat scripts", () => { + expect(getScriptDepth(flatAll)).toBe(1); + expect(getScriptDepth(flatAny)).toBe(1); + expect(getScriptDepth(flatAtLeast)).toBe(1); + }); + + it("should return 2 for hierarchical script", () => { + expect(getScriptDepth(hierarchical)).toBe(2); + }); + + it("should return 3 for deeply nested script", () => { + expect(getScriptDepth(deeplyNested)).toBe(3); + }); + + it("should return 0 for timelocks", () => { + expect(getScriptDepth({ type: "timelockStart", slot: "0" })).toBe(0); + }); +}); diff --git a/src/__tests__/signTransaction.test.ts b/src/__tests__/signTransaction.test.ts index 39c1958..f4b2e87 100644 --- a/src/__tests__/signTransaction.test.ts +++ b/src/__tests__/signTransaction.test.ts @@ -25,6 +25,34 @@ jest.mock( { virtual: true }, ); +const applyRateLimitMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, options?: unknown) => boolean +>(); +const enforceBodySizeMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, maxBytes: number) => boolean +>(); + +jest.mock( + '@/lib/security/requestGuards', + () => ({ + __esModule: true, + applyRateLimit: applyRateLimitMock, + enforceBodySize: enforceBodySizeMock, + }), + { virtual: true }, +); + +const getClientIPMock = jest.fn<(req: NextApiRequest) => string>(); + +jest.mock( + '@/lib/security/rateLimit', + () => ({ + __esModule: true, + getClientIP: getClientIPMock, + }), + { virtual: true }, +); + const createCallerMock = jest.fn(); jest.mock( @@ -82,6 +110,40 @@ jest.mock( { virtual: true }, ); +const shouldSubmitMultisigTxMock = jest.fn< + (wallet: unknown, signedAddressesCount: number) => boolean +>(); +const submitTxWithScriptRecoveryMock = jest.fn< + (args: { txHex: string; submitter: { submitTx: (txHex: string) => Promise } }) => Promise<{ txHash: string; txHex: string; repaired: boolean }> +>(); +const createVkeyWitnessFromHexMock = jest.fn< + (keyHex: string, signatureHex: string) => { + publicKey: MockPublicKey; + signature: MockEd25519Signature; + witness: MockVkeywitness; + keyHashHex: string; + } +>(); +const addUniqueVkeyWitnessToTxMock = jest.fn< + (originalTxHex: string, witnessToAdd: MockVkeywitness) => { + txHex: string; + witnessAdded: boolean; + vkeyWitnesses: MockVkeywitnesses; + } +>(); + +jest.mock( + '@/utils/txSignUtils', + () => ({ + __esModule: true, + createVkeyWitnessFromHex: createVkeyWitnessFromHexMock, + addUniqueVkeyWitnessToTx: addUniqueVkeyWitnessToTxMock, + shouldSubmitMultisigTx: shouldSubmitMultisigTxMock, + submitTxWithScriptRecovery: submitTxWithScriptRecoveryMock, + }), + { virtual: true }, +); + const resolvePaymentKeyHashMock = jest.fn<(address: string) => string>(); jest.mock( @@ -348,12 +410,19 @@ beforeEach(() => { dbTransactionUpdateManyMock.mockReset(); getProviderMock.mockReset(); addressToNetworkMock.mockReset(); + shouldSubmitMultisigTxMock.mockReset(); + submitTxWithScriptRecoveryMock.mockReset(); + createVkeyWitnessFromHexMock.mockReset(); + addUniqueVkeyWitnessToTxMock.mockReset(); resolvePaymentKeyHashMock.mockReset(); calculateTxHashMock.mockReset(); corsMock.mockReset(); addCorsCacheBustingHeadersMock.mockReset(); createCallerMock.mockReset(); verifyJwtMock.mockReset(); + applyRateLimitMock.mockReset(); + enforceBodySizeMock.mockReset(); + getClientIPMock.mockReset(); corsMock.mockResolvedValue(undefined); addCorsCacheBustingHeadersMock.mockImplementation(() => { @@ -362,6 +431,57 @@ beforeEach(() => { calculateTxHashMock.mockReturnValue('deadbeef'); resolvePaymentKeyHashMock.mockReturnValue(witnessKeyHashHex); addressToNetworkMock.mockReturnValue(0); + applyRateLimitMock.mockReturnValue(true); + enforceBodySizeMock.mockReturnValue(true); + getClientIPMock.mockReturnValue('127.0.0.1'); + shouldSubmitMultisigTxMock.mockReturnValue(true); + createVkeyWitnessFromHexMock.mockImplementation((keyHex, signatureHex) => { + const publicKey = MockPublicKey.from_hex(keyHex); + const signature = MockEd25519Signature.from_hex(signatureHex); + const witness = MockVkeywitness.new(MockVkey.new(publicKey), signature); + return { + publicKey, + signature, + witness, + keyHashHex: witnessKeyHashHex, + }; + }); + addUniqueVkeyWitnessToTxMock.mockImplementation((originalTxHex, witnessToAdd) => { + const mergedWitnesses = MockVkeywitnesses.from_bytes(); + const incomingKeyHash = Buffer.from( + witnessToAdd.vkey().public_key().hash().to_bytes(), + ).toString('hex').toLowerCase(); + + const existingWitnessCount = mergedWitnesses.len(); + for (let i = 0; i < existingWitnessCount; i++) { + const existingWitness = mergedWitnesses.get(i); + const existingKeyHash = Buffer.from( + existingWitness.vkey().public_key().hash().to_bytes(), + ).toString('hex').toLowerCase(); + + if (existingKeyHash === incomingKeyHash) { + return { + txHex: originalTxHex, + witnessAdded: false, + vkeyWitnesses: mergedWitnesses, + }; + } + } + + mergedWitnesses.add(witnessToAdd); + mergedWitnesses.to_bytes(); + + return { + txHex: 'updated-tx-hex', + witnessAdded: true, + vkeyWitnesses: mergedWitnesses, + }; + }); + submitTxWithScriptRecoveryMock.mockImplementation(async ({ txHex, submitter }) => ({ + txHash: await submitter.submitTx(txHex), + txHex, + repaired: false, + })); createCallerMock.mockReturnValue({ wallet: { getWallet: walletGetWalletMock }, @@ -445,13 +565,15 @@ describe('signTransaction API route', () => { expect(addCorsCacheBustingHeadersMock).toHaveBeenCalledWith(res); expect(corsMock).toHaveBeenCalledWith(req, res); expect(verifyJwtMock).toHaveBeenCalledWith('valid-token'); - expect(createCallerMock).toHaveBeenCalledWith({ - db: dbMock, - session: expect.objectContaining({ - user: { id: address }, - expires: expect.any(String), + expect(createCallerMock).toHaveBeenCalledWith( + expect.objectContaining({ + db: dbMock, + session: expect.objectContaining({ + user: { id: address }, + expires: expect.any(String), + }), }), - }); + ); expect(walletGetWalletMock).toHaveBeenCalledWith({ walletId, address }); expect(dbTransactionFindUniqueMock).toHaveBeenNthCalledWith(1, { where: { id: transactionId }, @@ -694,5 +816,105 @@ describe('signTransaction API route', () => { expect(dbTransactionUpdateManyMock).not.toHaveBeenCalled(); }); + it('persists repaired tx hex when script recovery succeeds', async () => { + const address = 'addr_test1qprecoverysuccess'; + const walletId = 'wallet-id-recovery'; + const transactionId = 'transaction-id-recovery'; + const signatureHex = 'aa'.repeat(64); + const keyHex = 'bb'.repeat(64); + + verifyJwtMock.mockReturnValue({ address }); + + walletGetWalletMock.mockResolvedValue({ + id: walletId, + type: 'atLeast', + numRequiredSigners: 1, + signersAddresses: [address], + }); + + const transactionRecord = { + id: transactionId, + walletId, + state: 0, + signedAddresses: [] as string[], + rejectedAddresses: [] as string[], + txCbor: 'stored-tx-hex', + txHash: null as string | null, + txJson: '{}', + }; + + const updatedTransaction = { + ...transactionRecord, + signedAddresses: [address], + txCbor: 'repaired-tx-hex', + state: 1, + txHash: 'recovered-hash', + txJson: '{"multisig":{"state":1}}', + }; + + dbTransactionFindUniqueMock + .mockResolvedValueOnce(transactionRecord) + .mockResolvedValueOnce(updatedTransaction); + + dbTransactionUpdateManyMock.mockResolvedValue({ count: 1 }); + + const submitTxMock = jest.fn<(txHex: string) => Promise>(); + submitTxMock.mockResolvedValue('should-not-be-used-directly'); + getProviderMock.mockReturnValue({ submitTx: submitTxMock }); + + submitTxWithScriptRecoveryMock.mockResolvedValueOnce({ + txHash: 'recovered-hash', + txHex: 'repaired-tx-hex', + repaired: true, + }); + + const req = { + method: 'POST', + headers: { authorization: 'Bearer valid-token' }, + body: { + walletId, + transactionId, + address, + signature: signatureHex, + key: keyHex, + }, + } as unknown as NextApiRequest; + + const res = createMockResponse(); + + await handler(req, res); + + expect(submitTxWithScriptRecoveryMock).toHaveBeenCalledWith( + expect.objectContaining({ + txHex: 'updated-tx-hex', + submitter: expect.objectContaining({ + submitTx: expect.any(Function), + }), + }), + ); + expect(dbTransactionUpdateManyMock).toHaveBeenCalledWith({ + where: { + id: transactionId, + signedAddresses: { equals: [] }, + rejectedAddresses: { equals: [] }, + txCbor: 'stored-tx-hex', + txJson: '{}', + }, + data: expect.objectContaining({ + signedAddresses: { set: [address] }, + rejectedAddresses: { set: [] }, + txCbor: 'repaired-tx-hex', + state: 1, + txHash: 'recovered-hash', + }), + }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + transaction: updatedTransaction, + submitted: true, + txHash: 'recovered-hash', + }); + }); + }); diff --git a/src/components/common/overall-layout/proxy-data-loader.tsx b/src/components/common/overall-layout/proxy-data-loader.tsx index e9bcaf7..d85f116 100644 --- a/src/components/common/overall-layout/proxy-data-loader.tsx +++ b/src/components/common/overall-layout/proxy-data-loader.tsx @@ -34,7 +34,7 @@ export default function ProxyDataLoader() { // Update store when API data changes useEffect(() => { if (apiProxies && appWallet?.id) { - const proxyData = apiProxies.map(proxy => ({ + const proxyData = apiProxies.map((proxy: NonNullable[number]) => ({ id: proxy.id, proxyAddress: proxy.proxyAddress, authTokenId: proxy.authTokenId, diff --git a/src/components/multisig/proxy/ProxyControl.tsx b/src/components/multisig/proxy/ProxyControl.tsx index 05e59a1..e575086 100644 --- a/src/components/multisig/proxy/ProxyControl.tsx +++ b/src/components/multisig/proxy/ProxyControl.tsx @@ -84,8 +84,27 @@ export default function ProxyControl() { { enabled: !!appWallet?.id } ); - // Use store proxies if available, otherwise fall back to API proxies - const proxies = useMemo(() => storeProxies.length > 0 ? storeProxies : (apiProxies ?? []), [storeProxies, apiProxies]); + // Merge API proxies with store-enriched data. + // API is source of truth for membership; store provides enriched fields (balance, drepInfo, etc). + const proxies = useMemo(() => { + const apiList = apiProxies ?? []; + + if (apiList.length === 0) { + return storeProxies; + } + + const storeById = new Map(storeProxies.map((proxy) => [proxy.id, proxy])); + const merged = apiList.map((proxy: NonNullable[number]) => { + const enriched = storeById.get(proxy.id); + return enriched ? { ...enriched, ...proxy } : proxy; + }); + + // Keep any locally cached proxies that have not reached API yet. + const apiIds = new Set(apiList.map((proxy: NonNullable[number]) => proxy.id)); + const storeOnly = storeProxies.filter((proxy) => !apiIds.has(proxy.id)); + + return [...merged, ...storeOnly]; + }, [storeProxies, apiProxies]); const proxiesLoading = storeLoading || apiLoading; const { mutateAsync: createProxy } = api.proxy.createProxy.useMutation({ diff --git a/src/components/multisig/proxy/offchain.ts b/src/components/multisig/proxy/offchain.ts index a936bd1..ca8944f 100644 --- a/src/components/multisig/proxy/offchain.ts +++ b/src/components/multisig/proxy/offchain.ts @@ -7,6 +7,7 @@ import { } from "@meshsdk/core"; import type { UTxO, MeshTxBuilder } from "@meshsdk/core"; // import { parseDatumCbor } from "@meshsdk/core-cst"; +import { DREP_DEPOSIT_STRING } from "@/utils/protocol-deposit-constants"; import { MeshTxInitiator } from "./common"; import type { MeshTxInitiatorInput } from "./common"; @@ -501,7 +502,7 @@ export class MeshProxyContract extends MeshTxInitiator { anchorDataHash: anchorHash!, }); } else if (action === "deregister") { - txHex.drepDeregistrationCertificate(drepId, "500000000"); + txHex.drepDeregistrationCertificate(drepId, DREP_DEPOSIT_STRING); } else if (action === "update") { txHex.drepUpdateCertificate(drepId, { anchorUrl: anchorUrl!, diff --git a/src/components/pages/homepage/wallets/index.tsx b/src/components/pages/homepage/wallets/index.tsx index 6935bfa..e6a6d37 100644 --- a/src/components/pages/homepage/wallets/index.tsx +++ b/src/components/pages/homepage/wallets/index.tsx @@ -103,10 +103,15 @@ export default function PageWallets() { }, ); - // Filter wallets for balance fetching (only non-archived or all if showing archived) - const walletsForBalance = wallets?.filter( - (wallet) => showArchived || !wallet.isArchived, - ) as Wallet[] | undefined; + // Keep a stable wallets array for balance fetching to avoid restarting the queue + // on unrelated rerenders. + const walletsForBalance = useMemo( + () => + wallets?.filter((wallet) => showArchived || !wallet.isArchived) as + | Wallet[] + | undefined, + [wallets, showArchived], + ); // Fetch balances with rate limiting const { balances, loadingStates } = useWalletBalances(walletsForBalance); diff --git a/src/components/pages/wallet/governance/drep/retire.tsx b/src/components/pages/wallet/governance/drep/retire.tsx index 4d723b8..afa9e72 100644 --- a/src/components/pages/wallet/governance/drep/retire.tsx +++ b/src/components/pages/wallet/governance/drep/retire.tsx @@ -14,6 +14,7 @@ import { MeshProxyContract } from "@/components/multisig/proxy/offchain"; import { api } from "@/utils/api"; import { useCallback } from "react"; import { useToast } from "@/hooks/use-toast"; +import { DREP_DEPOSIT_STRING } from "@/utils/protocol-deposit-constants"; export default function Retire({ appWallet, manualUtxos }: { appWallet: Wallet; manualUtxos: UTxO[] }) { const network = useSiteStore((state) => state.network); @@ -250,7 +251,7 @@ export default function Retire({ appWallet, manualUtxos }: { appWallet: Wallet; txBuilder .txInScript(scriptCbor) .changeAddress(changeAddress) - .drepDeregistrationCertificate(dRepId, "500000000"); + .drepDeregistrationCertificate(dRepId, DREP_DEPOSIT_STRING); // Only add certificateScript if it's different from the spending script // to avoid "extraneous scripts" error diff --git a/src/components/pages/wallet/info/inspect-script.tsx b/src/components/pages/wallet/info/inspect-script.tsx index 25772dc..9a0afb1 100644 --- a/src/components/pages/wallet/info/inspect-script.tsx +++ b/src/components/pages/wallet/info/inspect-script.tsx @@ -1,21 +1,39 @@ import Code from "@/components/ui/code"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Badge } from "@/components/ui/badge"; -import { ChevronDown, ChevronUp, Code2, Sparkles } from "lucide-react"; +import { ChevronDown, ChevronUp, Code2 } from "lucide-react"; import { useState } from "react"; import { Wallet } from "@/types/wallet"; +import { getWalletType } from "@/utils/common"; export function NativeScriptSection({ appWallet }: { appWallet: Wallet }) { const [isOpen, setIsOpen] = useState(false); - const isLegacyWallet = !!appWallet.rawImportBodies?.multisig; - - // For legacy wallets, the nativeScript is just a placeholder - // The actual script is stored as CBOR in scriptCbor - const hasValidNativeScript = !isLegacyWallet || - (appWallet.nativeScript && - 'scripts' in appWallet.nativeScript && - Array.isArray(appWallet.nativeScript.scripts) && - appWallet.nativeScript.scripts.length > 0); + const walletType = getWalletType(appWallet); + const isImportedWallet = !!appWallet.rawImportBodies?.multisig; + const isLegacyWallet = walletType === "legacy"; + + const isLogicalGroupType = + appWallet.nativeScript?.type === "all" || + appWallet.nativeScript?.type === "any" || + appWallet.nativeScript?.type === "atLeast"; + + const hasScriptsArray = + !!appWallet.nativeScript && + "scripts" in appWallet.nativeScript && + Array.isArray((appWallet.nativeScript as any).scripts); + + const hasNativeScript = !!appWallet.nativeScript; + + // We only treat it as a placeholder when it's an imported wallet AND the script is + // one of the logical group types with an empty scripts array (our known fallback shape). + const isPlaceholder = + isImportedWallet && + isLogicalGroupType && + hasScriptsArray && + (appWallet.nativeScript as any).scripts.length === 0; + + // If it's not the placeholder, we consider it decoded/real for display purposes. + const hasDecodedScript = isImportedWallet ? !isPlaceholder : hasNativeScript; return ( @@ -23,6 +41,11 @@ export function NativeScriptSection({ appWallet }: { appWallet: Wallet }) {
Native Script + {isImportedWallet && ( + + Imported + + )} {isLegacyWallet && ( Legacy @@ -36,38 +59,48 @@ export function NativeScriptSection({ appWallet }: { appWallet: Wallet }) { )} - {isLegacyWallet && ( + {isPlaceholder && (

- Legacy Wallet: This wallet was imported and doesn't use the SDK. - The Native Script JSON below is a placeholder. The actual script is stored as CBOR. + Imported Wallet: The Native Script JSON could not be decoded from CBOR. + The actual script is available as CBOR below.

)} - - {hasValidNativeScript && ( + + {(hasDecodedScript || !isImportedWallet) && (
Native Script JSON - {isLegacyWallet && (placeholder)} + {isImportedWallet && (decoded from CBOR)} + {isLegacyWallet && (generated from wallet signers)}
{JSON.stringify(appWallet.nativeScript, null, 2)}
)} - -
-
- Script CBOR - {isLegacyWallet && (actual script)} + + {!hasNativeScript && ( +
+

+ Native Script JSON is not available for this wallet. +

-
- {appWallet.scriptCbor} + )} + + {appWallet.scriptCbor && ( +
+
+ Script CBOR +
+
+ {appWallet.scriptCbor} +
-
- - {isLegacyWallet && appWallet.stakeScriptCbor && ( + )} + + {isImportedWallet && appWallet.stakeScriptCbor && (
Stake Script CBOR
diff --git a/src/components/pages/wallet/info/migration/ProxyTransferStep.tsx b/src/components/pages/wallet/info/migration/ProxyTransferStep.tsx index e2cd1e3..d1b8c24 100644 --- a/src/components/pages/wallet/info/migration/ProxyTransferStep.tsx +++ b/src/components/pages/wallet/info/migration/ProxyTransferStep.tsx @@ -120,7 +120,7 @@ export default function ProxyTransferStep({
{hasProxies ? (
- {(existingProxies ?? []).map((proxy, index) => ( + {(existingProxies ?? []).map((proxy: NonNullable[number], index: number) => (
diff --git a/src/components/pages/wallet/new-transaction/index.tsx b/src/components/pages/wallet/new-transaction/index.tsx index d8f5f75..4ad5e17 100644 --- a/src/components/pages/wallet/new-transaction/index.tsx +++ b/src/components/pages/wallet/new-transaction/index.tsx @@ -474,11 +474,20 @@ export default function PageNewTransaction({ onSuccess }: { onSuccess?: () => vo }); reset(); - // send discord message - await sendDiscordMessage( - discordIds, - `**NEW MULTISIG TRANSACTION:** A new Multisig transaction has been created for your wallet: ${appWallet.name}. Review it here: ${window.location.origin}/wallets/${appWallet.id}/transactions`, - ); + // Best-effort notification; creation success should not depend on Discord. + try { + await sendDiscordMessage( + discordIds, + `**NEW MULTISIG TRANSACTION:** A new Multisig transaction has been created for your wallet: ${appWallet.name}. Review it here: ${window.location.origin}/wallets/${appWallet.id}/transactions`, + ); + } catch (discordError) { + console.error("Discord notification failed:", discordError); + } + + if (onSuccess) { + onSuccess(); + return; + } router.push(`/wallets/${appWallet.id}/transactions`); } catch (e) { diff --git a/src/components/pages/wallet/staking/StakingActions/stake.tsx b/src/components/pages/wallet/staking/StakingActions/stake.tsx index 36ac6bc..b3d9670 100644 --- a/src/components/pages/wallet/staking/StakingActions/stake.tsx +++ b/src/components/pages/wallet/staking/StakingActions/stake.tsx @@ -3,13 +3,78 @@ import { Button } from "@/components/ui/button"; import { Loader } from "lucide-react"; import { StakingInfo } from "../stakingInfoCard"; import { Wallet } from "@/types/wallet"; -import { deserializePoolId, UTxO } from "@meshsdk/core"; +import { UTxO } from "@meshsdk/core"; import { MultisigWallet } from "@/utils/multisigSDK"; import { ToastAction } from "@radix-ui/react-toast"; import { toast } from "@/hooks/use-toast"; import { getTxBuilder } from "@/utils/get-tx-builder"; -import { getProvider } from "@/utils/get-provider"; +import { STAKE_KEY_DEPOSIT } from "@/utils/protocol-deposit-constants"; import useTransaction from "@/hooks/useTransaction"; + +type StakingAction = "register" | "deregister" | "delegate" | "withdrawal" | "registerAndDelegate"; + +type StakingActionConfig = { + execute: () => void; + description: string; + successTitle: string; + successMessage: string; +}; + +function shouldApplyStakeDeposit(action: StakingAction): boolean { + return action === "register" || action === "registerAndDelegate"; +} + +function buildStakingActionConfigs({ + txBuilder, + rewardAddress, + stakingScript, + poolHex, + rewards, +}: { + txBuilder: ReturnType; + rewardAddress: string; + stakingScript: string; + poolHex: string; + rewards: string; +}): Record { + return { + register: { + execute: () => txBuilder.registerStakeCertificate(rewardAddress).certificateScript(stakingScript), + description: "Register stake.", + successTitle: "Stake Registered", + successMessage: "Your stake address has been registered.", + }, + deregister: { + execute: () => txBuilder.deregisterStakeCertificate(rewardAddress).certificateScript(stakingScript), + description: "Deregister stake.", + successTitle: "Stake Deregistered", + successMessage: "Your stake address has been deregistered.", + }, + delegate: { + execute: () => txBuilder.delegateStakeCertificate(rewardAddress, poolHex).certificateScript(stakingScript), + description: "Delegate stake.", + successTitle: "Stake Delegated", + successMessage: "Your stake has been delegated.", + }, + withdrawal: { + execute: () => txBuilder.withdrawal(rewardAddress, rewards), + description: "Withdraw rewards.", + successTitle: "Rewards Withdrawn", + successMessage: "Your staking rewards have been withdrawn.", + }, + registerAndDelegate: { + execute: () => { + txBuilder + .registerStakeCertificate(rewardAddress) + .certificateScript(stakingScript); + txBuilder.delegateStakeCertificate(rewardAddress, poolHex).certificateScript(stakingScript); + }, + description: "Register & delegate stake.", + successTitle: "Stake Registered & Delegated", + successMessage: "Your stake address has been registered and delegated.", + }, + }; +} export default function StakeButton({ stakingInfo, appWallet, @@ -25,12 +90,12 @@ export default function StakeButton({ utxos: UTxO[]; network: number; poolHex: string; - action: "register" | "deregister" | "delegate" | "withdrawal" | "registerAndDelegate"; + action: StakingAction; }) { const { newTransaction } = useTransaction(); const [loading, setLoading] = useState(false); - async function Stake() { + async function handleStake() { setLoading(true); try { if (!mWallet) throw new Error("Multisig Wallet could not be built."); @@ -57,45 +122,17 @@ export default function StakeButton({ .txInScript(appWallet.scriptCbor); } - const actionsMap = { - register: { - execute: () => txBuilder.registerStakeCertificate(rewardAddress).certificateScript(stakingScript), - description: "Register stake.", - successTitle: "Stake Registered", - successMessage: "Your stake address has been registered.", - }, - deregister: { - execute: () => txBuilder.deregisterStakeCertificate(rewardAddress).certificateScript(stakingScript), - description: "Deregister stake.", - successTitle: "Stake Deregistered", - successMessage: "Your stake address has been deregistered.", - }, - delegate: { - execute: () => txBuilder.delegateStakeCertificate(rewardAddress, poolHex).certificateScript(stakingScript), - description: "Delegate stake.", - successTitle: "Stake Delegated", - successMessage: "Your stake has been delegated.", - }, - withdrawal: { - execute: () => txBuilder.withdrawal(rewardAddress, stakingInfo.rewards), - description: "Withdraw rewards.", - successTitle: "Rewards Withdrawn", - successMessage: "Your staking rewards have been withdrawn.", - }, - registerAndDelegate: { - execute: () => { - txBuilder.registerStakeCertificate(rewardAddress); - txBuilder.delegateStakeCertificate(rewardAddress, poolHex).certificateScript(stakingScript); - }, - description: "Register & delegate stake.", - successTitle: "Stake Registered & Delegated", - successMessage: "Your stake address has been registered and delegated.", - }, - }; + const actionConfigs = buildStakingActionConfigs({ + txBuilder, + rewardAddress, + stakingScript, + poolHex, + rewards: stakingInfo.rewards, + }); + const actionConfig = actionConfigs[action]; - const actionConfig = actionsMap[action]; - if (!actionConfig) { - throw new Error("Invalid staking action."); + if (shouldApplyStakeDeposit(action)) { + txBuilder.protocolParams({ keyDeposit: STAKE_KEY_DEPOSIT }); } actionConfig.execute(); @@ -152,7 +189,7 @@ export default function StakeButton({ } return ( - diff --git a/src/components/pages/wallet/transactions/all-transactions.tsx b/src/components/pages/wallet/transactions/all-transactions.tsx index 9c614bc..4d6a537 100644 --- a/src/components/pages/wallet/transactions/all-transactions.tsx +++ b/src/components/pages/wallet/transactions/all-transactions.tsx @@ -29,6 +29,7 @@ import useTransaction from "@/hooks/useTransaction"; import React, { useEffect, useMemo, useState } from "react"; import ResponsiveTransactionsTable from "./responsive-transactions-table"; import type { LucideIcon } from "lucide-react"; +import { DREP_DEPOSIT } from "@/utils/protocol-deposit-constants"; type CertificateInfo = { type: string; @@ -290,12 +291,12 @@ function TransactionRow({ <> {dbTransaction.description == "DRep registration" && (
- -{lovelaceToAda(500000000)} ₳ + -{lovelaceToAda(DREP_DEPOSIT)} ₳
)} {dbTransaction.description == "DRep retirement" && (
- +{lovelaceToAda(500000000)} ₳ + +{lovelaceToAda(DREP_DEPOSIT)} ₳
)} diff --git a/src/components/pages/wallet/transactions/transaction-card.tsx b/src/components/pages/wallet/transactions/transaction-card.tsx index e941110..6a92172 100644 --- a/src/components/pages/wallet/transactions/transaction-card.tsx +++ b/src/components/pages/wallet/transactions/transaction-card.tsx @@ -1,6 +1,9 @@ import React, { useMemo, useState } from "react"; -import { checkSignature, generateNonce } from "@meshsdk/core"; +import { + checkSignature, + generateNonce, +} from "@meshsdk/core"; import { useWallet } from "@meshsdk/react"; import { csl } from "@meshsdk/core-csl"; import useActiveWallet from "@/hooks/useActiveWallet"; @@ -53,7 +56,11 @@ import { import { get } from "http"; import { getProvider } from "@/utils/get-provider"; import { useSiteStore } from "@/lib/zustand/site"; - +import { + mergeSignerWitnesses, + shouldSubmitMultisigTx, + submitTxWithScriptRecovery, +} from "@/utils/txSignUtils"; export default function TransactionCard({ walletId, transaction, @@ -236,7 +243,12 @@ export default function TransactionCard({ try { setLoading(true); - const signedTx = await activeWallet.signTx(transaction.txCbor, true); + const signerWitnessPayload = await activeWallet.signTx(transaction.txCbor, true); + + let signedTx = mergeSignerWitnesses( + transaction.txCbor, + signerWitnessPayload, + ); // sanity check const tx = csl.Transaction.from_hex(signedTx); @@ -253,29 +265,25 @@ export default function TransactionCard({ duration: 5000, variant: "destructive", }); + return; } - const signedAddresses = transaction.signedAddresses; - signedAddresses.push(userAddress); + const signedAddresses = Array.from( + new Set([...transaction.signedAddresses, userAddress]), + ); let txHash = ""; - let submitTx = false; - - if ( - appWallet.type == "atLeast" && - appWallet.numRequiredSigners == signedAddresses.length - ) { - submitTx = true; - } else if ( - appWallet.type == "all" && - appWallet.signersAddresses.length == signedAddresses.length - ) { - submitTx = true; - } + const submitTx = shouldSubmitMultisigTx(appWallet, signedAddresses.length); if (submitTx) { - // Use BlockfrostProvider to submit transaction (works with both regular and UTXOS wallets) - txHash = await blockchainProvider.submitTx(signedTx); + const submitResult = await submitTxWithScriptRecovery({ + txHex: signedTx, + submitter: blockchainProvider, + appWallet, + network, + }); + txHash = submitResult.txHash; + signedTx = submitResult.txHex; } updateTransaction({ @@ -890,10 +898,13 @@ export default function TransactionCard({
- {appWallet.signersDescriptions[index] && - appWallet.signersDescriptions[index].length > 0 - ? appWallet.signersDescriptions[index] - : getFirstAndLast(signerAddress)} + {(() => { + const signerDescription = + appWallet.signersDescriptions?.[index]; + return signerDescription && signerDescription.length > 0 + ? signerDescription + : getFirstAndLast(signerAddress); + })()}
{isYou && ( diff --git a/src/hooks/useTransaction.ts b/src/hooks/useTransaction.ts index b35c777..78c1289 100644 --- a/src/hooks/useTransaction.ts +++ b/src/hooks/useTransaction.ts @@ -2,16 +2,144 @@ import { api } from "@/utils/api"; import { useToast } from "./use-toast"; import { useCallback } from "react"; import { useSiteStore } from "@/lib/zustand/site"; -import { useUserStore } from "@/lib/zustand/user"; import useAppWallet from "./useAppWallet"; import { MeshTxBuilder } from "@meshsdk/core"; +import { csl } from "@meshsdk/core-csl"; import useActiveWallet from "./useActiveWallet"; +import { + mergeSignerWitnesses, + shouldSubmitMultisigTx, + submitTxWithScriptRecovery, +} from "@/utils/txSignUtils"; +import { getProvider } from "@/utils/get-provider"; +import { + DREP_DEPOSIT_LOVELACE, + STAKE_KEY_DEPOSIT_LOVELACE, +} from "@/utils/protocol-deposit-constants"; + +function parseCoinToBigInt(value: unknown, fallback: bigint): bigint { + if (typeof value === "bigint") return value; + if (typeof value === "number" && Number.isFinite(value)) { + return BigInt(Math.trunc(value)); + } + if (typeof value === "string" && value.trim().length > 0) { + try { + return BigInt(value); + } catch { + return fallback; + } + } + return fallback; +} + +function getCertificateCoinDelta(txBuilder: MeshTxBuilder): bigint { + const body = txBuilder.meshTxBuilderBody as { + certificates?: Array<{ + certType?: { + type?: string; + coin?: string | number | bigint; + deposit?: string | number | bigint; + }; + }>; + }; + const certs = body.certificates ?? []; + + let delta = 0n; + for (const cert of certs) { + const certType = cert?.certType?.type ?? ""; + const normalized = certType.toLowerCase(); + const certCoin = cert?.certType?.coin ?? cert?.certType?.deposit; + + if (normalized.includes("drepregistration")) { + delta -= parseCoinToBigInt(certCoin, DREP_DEPOSIT_LOVELACE); + continue; + } + if (normalized.includes("drepderegistration")) { + delta += parseCoinToBigInt(certCoin, DREP_DEPOSIT_LOVELACE); + continue; + } + + const isStakeRegister = + normalized.includes("registerstake") || normalized.includes("stakeregistration"); + const isStakeDeregister = + normalized.includes("deregisterstake") || + normalized.includes("unregisterstake") || + normalized.includes("stakederegistration"); + + if (isStakeRegister) { + delta -= STAKE_KEY_DEPOSIT_LOVELACE; + continue; + } + if (isStakeDeregister) { + delta += STAKE_KEY_DEPOSIT_LOVELACE; + } + } + + return delta; +} + +function adjustTxForStakeKeyDeposit( + unsignedTxHex: string, + coinDelta: bigint, + changeAddress?: string, +): string { + if (coinDelta === 0n) { + return unsignedTxHex; + } + + const tx = csl.Transaction.from_hex(unsignedTxHex); + const bodyJson = JSON.parse(tx.body().to_json()) as { + outputs?: Array<{ address?: string; amount?: { coin?: string } }>; + }; + const outputs = bodyJson.outputs ?? []; + if (outputs.length === 0) { + return unsignedTxHex; + } + + let changeOutputIndex = -1; + if (changeAddress) { + for (let i = 0; i < outputs.length; i++) { + const output = outputs[i]; + if (output?.address === changeAddress) { + changeOutputIndex = i; + } + } + } + + if (changeOutputIndex < 0) { + changeOutputIndex = outputs.length - 1; + } + + const changeOutput = outputs[changeOutputIndex]; + const currentCoin = BigInt(changeOutput?.amount?.coin ?? "0"); + if (!changeOutput?.amount?.coin) { + return unsignedTxHex; + } + + const adjustedCoin = currentCoin + coinDelta; + if (adjustedCoin <= 0n) { + return unsignedTxHex; + } + changeOutput.amount.coin = adjustedCoin.toString(); + + const adjustedTxBody = csl.TransactionBody.from_json(JSON.stringify(bodyJson)); + const adjustedTx = csl.Transaction.new( + adjustedTxBody, + csl.TransactionWitnessSet.from_bytes(tx.witness_set().to_bytes()), + tx.auxiliary_data(), + ); + if (!tx.is_valid()) { + adjustedTx.set_is_valid(false); + } + return adjustedTx.to_hex(); +} export default function useTransaction() { const ctx = api.useUtils(); const { toast } = useToast(); const { activeWallet, userAddress } = useActiveWallet(); const setLoading = useSiteStore((state) => state.setLoading); + const network = useSiteStore((state) => state.network); const { appWallet } = useAppWallet(); const { mutateAsync: createTransaction } = @@ -96,13 +224,28 @@ export default function useTransaction() { }); } - const unsignedTx = await data.txBuilder.complete(); + let unsignedTx = await data.txBuilder.complete(); + + // Workaround for certificate txs where builder-produced change may not + // fully account for deposit charge/refund in downstream validation. + const certificateCoinDelta = getCertificateCoinDelta(data.txBuilder); + if (certificateCoinDelta !== 0n) { + unsignedTx = adjustTxForStakeKeyDeposit( + unsignedTx, + certificateCoinDelta, + appWallet.address, + ); + } if (!activeWallet) { throw new Error("No wallet available for signing transaction"); } - const signedTx = await activeWallet.signTx(unsignedTx, true); + const signerWitnessPayload = await activeWallet.signTx(unsignedTx, true); + let signedTx = mergeSignerWitnesses( + unsignedTx, + signerWitnessPayload, + ); const signedAddresses = []; @@ -112,24 +255,18 @@ export default function useTransaction() { //Todo refactor to as util with Signable. - let submitTx = false; - - if (appWallet.type == "any") { - submitTx = true; - } else if ( - appWallet.type == "atLeast" && - appWallet.numRequiredSigners == signedAddresses.length - ) { - submitTx = true; - } else if ( - appWallet.type == "all" && - appWallet.signersAddresses.length == signedAddresses.length - ) { - submitTx = true; - } + const submitTx = shouldSubmitMultisigTx(appWallet, signedAddresses.length); if (submitTx) { - txHash = await activeWallet.submitTx(signedTx); + const blockchainProvider = getProvider(network); + const submitResult = await submitTxWithScriptRecovery({ + txHex: signedTx, + submitter: blockchainProvider, + appWallet, + network, + }); + txHash = submitResult.txHash; + signedTx = submitResult.txHex; } await createTransaction({ @@ -150,7 +287,7 @@ export default function useTransaction() { duration: 10000, }); }, - [appWallet, userAddress, activeWallet, createTransaction, setLoading, toast], + [appWallet, userAddress, activeWallet, createTransaction, setLoading, toast, network], ); return { newTransaction }; diff --git a/src/hooks/useUserWallets.ts b/src/hooks/useUserWallets.ts index 2420f1e..adde9bc 100644 --- a/src/hooks/useUserWallets.ts +++ b/src/hooks/useUserWallets.ts @@ -3,6 +3,7 @@ import { useSiteStore } from "@/lib/zustand/site"; import { api } from "@/utils/api"; import { buildWallet } from "@/utils/common"; import { DbWalletWithLegacy } from "@/types/wallet"; +import { useMemo } from "react"; export default function useUserWallets() { const network = useSiteStore((state) => state.network); @@ -52,16 +53,13 @@ export default function useUserWallets() { }, ); - let _wallets = wallets; + const mappedWallets = useMemo(() => { + if (!wallets) return undefined; - if (wallets) { - _wallets = wallets + return wallets .filter((wallet): wallet is DbWalletWithLegacy => wallet != null) - .map((wallet) => { - return buildWallet(wallet, network); - }); - return { wallets: _wallets, isLoading }; - } + .map((wallet) => buildWallet(wallet, network)); + }, [wallets, network]); - return { wallets: undefined, isLoading }; + return { wallets: mappedWallets, isLoading }; } diff --git a/src/hooks/useWalletBalances.ts b/src/hooks/useWalletBalances.ts index 65ec815..376a14c 100644 --- a/src/hooks/useWalletBalances.ts +++ b/src/hooks/useWalletBalances.ts @@ -1,10 +1,10 @@ import { useEffect, useRef, useState, useCallback } from "react"; -import { UTxO } from "@meshsdk/core"; +import { serializeNativeScript } from "@meshsdk/core"; import { Wallet } from "@/types/wallet"; import { getProvider } from "@/utils/get-provider"; -import { getBalanceFromUtxos } from "@/utils/getBalance"; import { addressToNetwork } from "@/utils/multisigSDK"; -import { buildMultisigWallet } from "@/utils/common"; +import { buildMultisigWallet, buildWallet, getWalletType } from "@/utils/common"; +import { scriptHashFromCbor } from "@/utils/nativeScriptUtils"; import { useSiteStore } from "@/lib/zustand/site"; import { useWalletBalancesStore } from "@/lib/zustand/wallet-balances"; @@ -20,6 +20,36 @@ interface UseWalletBalancesOptions { cooldownMs?: number; } +function isKnown404Error(error: unknown): boolean { + const maybeError = error as { + response?: { status?: unknown; data?: { status_code?: unknown } }; + status?: unknown; + data?: { status_code?: unknown }; + }; + + const responseStatus = maybeError.response?.status; + if (typeof responseStatus === "number") { + return responseStatus === 404; + } + + const responseDataStatus = maybeError.response?.data?.status_code; + if (typeof responseDataStatus === "number") { + return responseDataStatus === 404; + } + + const topLevelStatus = maybeError.status; + if (typeof topLevelStatus === "number") { + return topLevelStatus === 404; + } + + const topLevelDataStatus = maybeError.data?.status_code; + if (typeof topLevelDataStatus === "number") { + return topLevelDataStatus === 404; + } + + return false; +} + export default function useWalletBalances( wallets: Wallet[] | undefined, options: UseWalletBalancesOptions = {}, @@ -53,6 +83,77 @@ export default function useWalletBalances( } }, [clearExpiredBalances]); + // Abort any in-flight processing only when the hook unmounts. + // Avoid aborting on every render/dependency update. + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + const getCanonicalWalletAddress = useCallback( + (wallet: Wallet): string => { + // Goal: get the address we should query Blockfrost with, without throwing for + // legacy/summon wallets (which do not have an SDK MultisigWallet). + try { + const walletType = getWalletType(wallet); + + // Prefer deriving network from the best available address. + const fallbackAddress = + wallet.rawImportBodies?.multisig?.address || + wallet.signersAddresses?.find((a) => !!a) || + wallet.address; + const walletNetwork = fallbackAddress + ? addressToNetwork(fallbackAddress) + : network; + + if (walletType === "sdk") { + const mWallet = buildMultisigWallet(wallet, walletNetwork); + return mWallet?.getScript().address || wallet.address; + } + + if (walletType === "summon") { + const importedAddress = + wallet.rawImportBodies?.multisig?.address || wallet.address; + const importedPaymentCbor = + wallet.rawImportBodies?.multisig?.payment_script; + const summonWallet = buildWallet(wallet, walletNetwork); + + // Build payment CBOR from the wallet's native script and compare hashes + // with imported payment CBOR to ensure we are checking the same script. + const builtPaymentCbor = serializeNativeScript( + summonWallet.nativeScript, + undefined, + walletNetwork, + ).scriptCbor; + const importedPaymentHash = scriptHashFromCbor(importedPaymentCbor); + const builtPaymentHash = scriptHashFromCbor(builtPaymentCbor); + + if ( + importedPaymentHash && + builtPaymentHash && + importedPaymentHash !== builtPaymentHash + ) { + console.warn( + `[useWalletBalances] Summon payment script mismatch for wallet ${wallet.id}: importedHash=${importedPaymentHash}, builtHash=${builtPaymentHash}`, + ); + return importedAddress || summonWallet.address; + } + + return summonWallet.address || importedAddress; + } + + // legacy + return buildWallet(wallet, walletNetwork).address; + } catch { + return wallet.address; + } + }, + [network], + ); + const fetchWalletBalance = useCallback( async (wallet: Wallet): Promise => { // Skip if already fetched in this session @@ -72,23 +173,13 @@ export default function useWalletBalances( // Mark as loading setLoadingStates((prev) => ({ ...prev, [wallet.id]: "loading" })); + const walletAddress = getCanonicalWalletAddress(wallet); try { - // Rebuild the multisig wallet to get the correct address - // This ensures we use the canonical script address, not the potentially wrong wallet.address - // Determine network from signer addresses or use the current network - const walletNetwork = wallet.signersAddresses.length > 0 - ? addressToNetwork(wallet.signersAddresses[0]!) - : network; - - const mWallet = buildMultisigWallet(wallet, walletNetwork); - if (!mWallet) { - throw new Error("Failed to build multisig wallet"); - } - - // Get the correct address from the multisig wallet script - const walletAddress = mWallet.getScript().address; - + // Use a canonical address depending on wallet type. + // SDK wallets: script address from MultisigWallet + // Summon wallets: stored rawImportBodies.multisig.address + // Legacy wallets: derived script address from payment keys // Use the network determined from the address const addressNetwork = addressToNetwork(walletAddress); const provider = getProvider(addressNetwork); @@ -119,32 +210,16 @@ export default function useWalletBalances( setBalance(wallet.id, balance, walletAddress); fetchedWalletsRef.current.add(wallet.id); - } catch (error: any) { - // Handle 404 errors gracefully (address doesn't exist yet - this is normal for unused addresses) - // Check multiple possible error formats from Blockfrost API - const is404 = - error?.response?.status === 404 || - error?.data?.status_code === 404 || - error?.status === 404 || - (error?.message && error.message.includes("404")) || - (error?.message && error.message.includes("Not Found")); - + } catch (error: unknown) { + // 404 is expected for never-used addresses. + const is404 = isKnown404Error(error); + if (is404) { - // 404 is expected for unused addresses - set balance to 0 (not null) and mark as loaded - const walletNetwork = wallet.signersAddresses.length > 0 - ? addressToNetwork(wallet.signersAddresses[0]!) - : network; - const mWallet = buildMultisigWallet(wallet, walletNetwork); - const walletAddress = mWallet?.getScript().address || wallet.address; - + // Set balance to 0 and cache to avoid repeated lookups for fresh addresses. setBalances((prev) => ({ ...prev, [wallet.id]: 0 })); setLoadingStates((prev) => ({ ...prev, [wallet.id]: "loaded" })); - - // Cache 404 results too (balance = 0) to avoid repeated requests setBalance(wallet.id, 0, walletAddress); - fetchedWalletsRef.current.add(wallet.id); - // Don't log 404 errors - they're expected for unused addresses } else { // Only log non-404 errors console.error(`Error fetching balance for wallet ${wallet.id}:`, error); @@ -154,7 +229,7 @@ export default function useWalletBalances( } } }, - [network, getCachedBalance, setBalance], + [network, getCachedBalance, setBalance, getCanonicalWalletAddress], ); const processQueue = useCallback(async () => { @@ -288,12 +363,6 @@ export default function useWalletBalances( }); } - // Cleanup on unmount - return () => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - }; }, [wallets, processQueue]); return { diff --git a/src/pages/api/v1/nativeScript.ts b/src/pages/api/v1/nativeScript.ts index 196110b..a096bb1 100644 --- a/src/pages/api/v1/nativeScript.ts +++ b/src/pages/api/v1/nativeScript.ts @@ -8,6 +8,10 @@ import { db } from "@/server/db"; import { DbWalletWithLegacy } from "@/types/wallet"; import { applyRateLimit } from "@/lib/security/requestGuards"; import { getClientIP } from "@/lib/security/rateLimit"; +import { + decodeNativeScriptFromCbor, + decodedToNativeScript, +} from "@/utils/nativeScriptUtils"; export default async function handler( req: NextApiRequest, @@ -71,10 +75,48 @@ export default async function handler( if (!walletFetch) { return res.status(404).json({ error: "Wallet not found" }); } - const mWallet = buildMultisigWallet(walletFetch as DbWalletWithLegacy); + const dbWallet = walletFetch as DbWalletWithLegacy; + const mWallet = buildMultisigWallet(dbWallet); + + // If SDK wallet not available, try to decode from stored CBOR (imported wallets) if (!mWallet) { - return res.status(500).json({ error: "Wallet could not be constructed" }); + const multisig = dbWallet.rawImportBodies?.multisig; + const paymentCbor = multisig?.payment_script; + const stakeCbor = multisig?.stake_script; + + const decodedScripts: Array<{ type: string; script: unknown }> = []; + + if (paymentCbor) { + try { + const decoded = decodeNativeScriptFromCbor(paymentCbor); + decodedScripts.push({ type: "payment", script: decodedToNativeScript(decoded) }); + } catch { + // keep going; stake script may still decode + } + } + + if (stakeCbor) { + try { + const decoded = decodeNativeScriptFromCbor(stakeCbor); + decodedScripts.push({ type: "stake", script: decodedToNativeScript(decoded) }); + } catch { + // ignore + } + } + + if (decodedScripts.length > 0) { + res.setHeader( + "Cache-Control", + "private, max-age=300, stale-while-revalidate=600", + ); + return res.status(200).json(decodedScripts); + } + + return res.status(500).json({ + error: "Wallet could not be constructed", + }); } + const types = mWallet.getAvailableTypes(); if (!types) { return res.status(500).json({ error: "Wallet could not be constructed" }); diff --git a/src/pages/api/v1/signTransaction.ts b/src/pages/api/v1/signTransaction.ts index dfa4bdb..9ec62af 100644 --- a/src/pages/api/v1/signTransaction.ts +++ b/src/pages/api/v1/signTransaction.ts @@ -6,8 +6,14 @@ import { createCaller } from "@/server/api/root"; import { db } from "@/server/db"; import { getProvider } from "@/utils/get-provider"; import { addressToNetwork } from "@/utils/multisigSDK"; +import { + addUniqueVkeyWitnessToTx, + createVkeyWitnessFromHex, + shouldSubmitMultisigTx, + submitTxWithScriptRecovery, +} from "@/utils/txSignUtils"; import { resolvePaymentKeyHash } from "@meshsdk/core"; -import { csl, calculateTxHash } from "@meshsdk/core-csl"; +import { calculateTxHash } from "@meshsdk/core-csl"; import { applyRateLimit, enforceBodySize } from "@/lib/security/requestGuards"; import { getClientIP } from "@/lib/security/rateLimit"; @@ -179,49 +185,25 @@ export default async function handler( return res.status(500).json({ error: "Stored transaction is missing txCbor" }); } - let parsedStoredTx: ReturnType; - try { - parsedStoredTx = csl.Transaction.from_hex(storedTxHex); - } catch (error: unknown) { - console.error("Failed to parse stored transaction", toError(error)); - return res.status(500).json({ error: "Invalid stored transaction data" }); - } - - const txBodyClone = csl.TransactionBody.from_bytes( - parsedStoredTx.body().to_bytes(), - ); - const witnessSetClone = csl.TransactionWitnessSet.from_bytes( - parsedStoredTx.witness_set().to_bytes(), - ); - - let vkeyWitnesses = witnessSetClone.vkeys(); - if (!vkeyWitnesses) { - vkeyWitnesses = csl.Vkeywitnesses.new(); - witnessSetClone.set_vkeys(vkeyWitnesses); - } else { - vkeyWitnesses = csl.Vkeywitnesses.from_bytes(vkeyWitnesses.to_bytes()); - witnessSetClone.set_vkeys(vkeyWitnesses); - } - const signatureHex = normalizeHex(signature, "signature"); const keyHex = normalizeHex(key, "key"); - let witnessPublicKey: csl.PublicKey; - let witnessSignature: csl.Ed25519Signature; - let witnessToAdd: csl.Vkeywitness; + let witnessPublicKey: ReturnType["publicKey"]; + let witnessSignature: ReturnType["signature"]; + let witnessToAdd: ReturnType["witness"]; + let witnessKeyHash: string; try { - witnessPublicKey = csl.PublicKey.from_hex(keyHex); - witnessSignature = csl.Ed25519Signature.from_hex(signatureHex); - const vkey = csl.Vkey.new(witnessPublicKey); - witnessToAdd = csl.Vkeywitness.new(vkey, witnessSignature); + const witnessDetails = createVkeyWitnessFromHex(keyHex, signatureHex); + witnessPublicKey = witnessDetails.publicKey; + witnessSignature = witnessDetails.signature; + witnessToAdd = witnessDetails.witness; + witnessKeyHash = witnessDetails.keyHashHex; } catch (error: unknown) { console.error("Invalid signature payload", toError(error)); return res.status(400).json({ error: "Invalid signature payload" }); } - const witnessKeyHash = toHex(witnessPublicKey.hash().to_bytes()).toLowerCase(); - let addressKeyHash: string; try { addressKeyHash = resolvePaymentKeyHash(address).toLowerCase(); @@ -236,7 +218,14 @@ export default async function handler( .json({ error: "Signature public key does not match address" }); } - const txHashHex = calculateTxHash(parsedStoredTx.to_hex()).toLowerCase(); + let txHashHex: string; + try { + txHashHex = calculateTxHash(storedTxHex).toLowerCase(); + } catch (error: unknown) { + console.error("Failed to hash stored transaction", toError(error)); + return res.status(500).json({ error: "Invalid stored transaction data" }); + } + const txHashBytes = Buffer.from(txHashHex, "hex"); const isSignatureValid = witnessPublicKey.verify(txHashBytes, witnessSignature); @@ -244,39 +233,28 @@ export default async function handler( return res.status(401).json({ error: "Invalid signature for transaction" }); } - const existingWitnessCount = vkeyWitnesses.len(); - for (let i = 0; i < existingWitnessCount; i++) { - const existingWitness = vkeyWitnesses.get(i); - const existingKeyHash = toHex( - existingWitness.vkey().public_key().hash().to_bytes(), - ).toLowerCase(); - if (existingKeyHash === witnessKeyHash) { + let txHexForUpdate = storedTxHex; + let vkeyWitnesses: ReturnType["vkeyWitnesses"]; + try { + const mergeResult = addUniqueVkeyWitnessToTx(storedTxHex, witnessToAdd); + if (!mergeResult.witnessAdded) { return res .status(409) .json({ error: "Witness for this address already exists" }); } + txHexForUpdate = mergeResult.txHex; + vkeyWitnesses = mergeResult.vkeyWitnesses; + } catch (error: unknown) { + console.error("Failed to merge witness into transaction", toError(error)); + return res.status(500).json({ error: "Invalid stored transaction data" }); } - vkeyWitnesses.add(witnessToAdd); - - const updatedTx = csl.Transaction.new( - txBodyClone, - witnessSetClone, - parsedStoredTx.auxiliary_data(), - ); - if (!parsedStoredTx.is_valid()) { - updatedTx.set_is_valid(false); - } - const txHexForUpdate = updatedTx.to_hex(); - const witnessSummaries: { keyHashHex: string; publicKeyBech32: string; signatureHex: string; }[] = []; - const witnessSetForExport = csl.Vkeywitnesses.from_bytes( - vkeyWitnesses.to_bytes(), - ); + const witnessSetForExport = vkeyWitnesses; const witnessCountForExport = witnessSetForExport.len(); for (let i = 0; i < witnessCountForExport; i++) { const witness = witnessSetForExport.get(i); @@ -291,19 +269,6 @@ export default async function handler( const shouldAttemptBroadcast = coerceBoolean(rawBroadcast, true); - const threshold = (() => { - switch (wallet.type) { - case "atLeast": - return wallet.numRequiredSigners ?? wallet.signersAddresses.length; - case "all": - return wallet.signersAddresses.length; - case "any": - return 1; - default: - return wallet.numRequiredSigners ?? 1; - } - })(); - let nextState = transaction.state; let finalTxHash = transaction.txHash ?? undefined; let submissionError: string | undefined; @@ -337,14 +302,19 @@ export default async function handler( if ( shouldAttemptBroadcast && - threshold > 0 && - updatedSignedAddresses.length >= threshold + shouldSubmitMultisigTx(wallet, updatedSignedAddresses.length) ) { try { const network = resolveNetworkId(); const provider = getProvider(network); - const submittedHash = await provider.submitTx(txHexForUpdate); - finalTxHash = submittedHash; + const submitResult = await submitTxWithScriptRecovery({ + txHex: txHexForUpdate, + submitter: provider, + appWallet: wallet, + network, + }); + finalTxHash = submitResult.txHash; + txHexForUpdate = submitResult.txHex; nextState = 1; } catch (error: unknown) { const err = toError(error); diff --git a/src/server/api/routers/proxy.ts b/src/server/api/routers/proxy.ts index 5afe425..864e0c5 100644 --- a/src/server/api/routers/proxy.ts +++ b/src/server/api/routers/proxy.ts @@ -10,15 +10,23 @@ const requireSessionAddress = (ctx: any) => { return address; }; -const assertWalletAccess = async (ctx: any, walletId: string, requester: string) => { +const assertWalletAccess = async ( + ctx: any, + walletId: string, + requester: string | string[], +) => { const wallet = await ctx.db.wallet.findUnique({ where: { id: walletId } }); if (!wallet) { throw new TRPCError({ code: "NOT_FOUND", message: "Wallet not found" }); } + const requesters = Array.isArray(requester) ? requester : [requester]; + const sessionWallets: string[] = (ctx as any).sessionWallets ?? []; + const allRequesters = [...requesters, ...sessionWallets]; const isSigner = - Array.isArray(wallet.signersAddresses) && wallet.signersAddresses.includes(requester); - const isOwner = wallet.ownerAddress === requester || wallet.ownerAddress === "all"; - if (!isSigner && !isOwner) { + Array.isArray(wallet.signersAddresses) && + wallet.signersAddresses.some((addr: string) => allRequesters.includes(addr)); + // Wallet model does not include ownerAddress; signer membership is the access control source. + if (!isSigner) { throw new TRPCError({ code: "FORBIDDEN", message: "Not authorized for this wallet" }); } return wallet; @@ -32,6 +40,100 @@ const getUserIdForAddress = async (ctx: any, address: string) => { return user.id; }; +const assertProxyAccess = async ( + ctx: any, + proxy: { walletId: string | null; userId: string | null }, + requesterAddresses: string[], +) => { + if (proxy.walletId) { + let hasWalletAccess = false; + for (const addr of requesterAddresses) { + try { + await assertWalletAccess(ctx, proxy.walletId, addr); + hasWalletAccess = true; + break; + } catch { + // Continue checking with remaining addresses + } + } + if (hasWalletAccess) return; + } + + if (proxy.userId) { + for (const addr of requesterAddresses) { + try { + const requesterUserId = await getUserIdForAddress(ctx, addr); + if (requesterUserId === proxy.userId) { + return; + } + } catch { + // Continue checking with remaining addresses + } + } + } + + throw new TRPCError({ code: "FORBIDDEN", message: "Not authorized for this proxy" }); +}; + +const getRequesterAddresses = (ctx: any): string[] => { + const sessionWallets: string[] = (ctx as any).sessionWallets ?? []; + return sessionWallets.length ? sessionWallets : [requireSessionAddress(ctx)]; +}; + +const assertWalletAccessForAnyAddress = async ( + ctx: any, + walletId: string, + addresses: string[], +) => { + for (const addr of addresses) { + try { + await assertWalletAccess(ctx, walletId, addr); + return; + } catch { + // continue checking next address + } + } + throw new TRPCError({ code: "FORBIDDEN", message: "Not authorized for this wallet" }); +}; + +const listActiveProxiesByUserAddress = async (ctx: any, userAddress: string) => { + return ctx.db.$queryRaw>` + SELECT p.* + FROM "Proxy" p + INNER JOIN "User" u ON p."userId" = u.id + WHERE u.address = ${userAddress} + AND p."isActive" = true + ORDER BY p."createdAt" DESC + `; +}; + +const assertProxyManageAccess = async ( + ctx: any, + proxy: { walletId: string | null; userId: string | null }, + sessionAddress: string, +) => { + if (proxy.walletId) { + await assertWalletAccess(ctx, proxy.walletId, sessionAddress); + } + if (proxy.userId) { + const sessionUserId = await getUserIdForAddress(ctx, sessionAddress); + if (sessionUserId !== proxy.userId) { + throw new TRPCError({ code: "FORBIDDEN", message: "User mismatch" }); + } + } +}; + export const proxyRouter = createTRPCRouter({ getUserByAddress: protectedProcedure .input(z.object({ address: z.string() })) @@ -105,36 +207,11 @@ export const proxyRouter = createTRPCRouter({ getProxiesByUser: protectedProcedure .input(z.object({ userAddress: z.string() })) .query(async ({ ctx, input }) => { - const sessionWallets: string[] = (ctx as any).sessionWallets ?? []; - const addresses = sessionWallets.length - ? sessionWallets - : [requireSessionAddress(ctx)]; + const addresses = getRequesterAddresses(ctx); if (!addresses.includes(input.userAddress)) { throw new TRPCError({ code: "FORBIDDEN", message: "Address mismatch" }); } - // Optimized: Use a single query with raw SQL to avoid N+1 - // This performs a JOIN in a single database round trip - const proxies = await ctx.db.$queryRaw>` - SELECT p.* - FROM "Proxy" p - INNER JOIN "User" u ON p."userId" = u.id - WHERE u.address = ${input.userAddress} - AND p."isActive" = true - ORDER BY p."createdAt" DESC - `; - - return proxies; + return listActiveProxiesByUserAddress(ctx, input.userAddress); }), getProxiesByUserOrWallet: protectedProcedure @@ -143,26 +220,10 @@ export const proxyRouter = createTRPCRouter({ userAddress: z.string().optional(), })) .query(async ({ ctx, input }) => { - const sessionWallets: string[] = (ctx as any).sessionWallets ?? []; - const addresses = sessionWallets.length - ? sessionWallets - : [requireSessionAddress(ctx)]; + const addresses = getRequesterAddresses(ctx); // Prefer fetching by walletId when available (already optimized with index) if (input.walletId) { - // Any authorized wallet that is a signer/owner grants access - let authorized = false; - for (const addr of addresses) { - try { - await assertWalletAccess(ctx, input.walletId, addr); - authorized = true; - break; - } catch { - // try next address - } - } - if (!authorized) { - throw new TRPCError({ code: "FORBIDDEN", message: "Not authorized for this wallet" }); - } + await assertWalletAccessForAnyAddress(ctx, input.walletId, addresses); return ctx.db.proxy.findMany({ where: { walletId: input.walletId, @@ -178,27 +239,7 @@ export const proxyRouter = createTRPCRouter({ if (!addresses.includes(input.userAddress)) { throw new TRPCError({ code: "FORBIDDEN", message: "Address mismatch" }); } - const proxies = await ctx.db.$queryRaw>` - SELECT p.* - FROM "Proxy" p - INNER JOIN "User" u ON p."userId" = u.id - WHERE u.address = ${input.userAddress} - AND p."isActive" = true - ORDER BY p."createdAt" DESC - `; - - return proxies; + return listActiveProxiesByUserAddress(ctx, input.userAddress); } // No criteria provided @@ -208,11 +249,19 @@ export const proxyRouter = createTRPCRouter({ getProxyById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { - return ctx.db.proxy.findUnique({ + const proxy = await ctx.db.proxy.findUnique({ where: { id: input.id, }, }); + if (!proxy) { + throw new TRPCError({ code: "NOT_FOUND", message: "Proxy not found" }); + } + + const addresses = getRequesterAddresses(ctx); + await assertProxyAccess(ctx, proxy, addresses); + + return proxy; }), updateProxy: protectedProcedure @@ -231,15 +280,16 @@ export const proxyRouter = createTRPCRouter({ if (!proxy) { throw new TRPCError({ code: "NOT_FOUND", message: "Proxy not found" }); } - if (proxy.walletId) { - await assertWalletAccess(ctx, proxy.walletId, sessionAddress); - } - if (proxy.userId) { - const sessionUserId = await getUserIdForAddress(ctx, sessionAddress); - if (sessionUserId !== proxy.userId) { - throw new TRPCError({ code: "FORBIDDEN", message: "User mismatch" }); - } + await assertProxyManageAccess(ctx, proxy, sessionAddress); + + if (input.walletId !== undefined || input.userId !== undefined) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Changing proxy ownership is not allowed in updateProxy. Use transferProxies or recreate the proxy.", + }); } + return ctx.db.proxy.update({ where: { id: input.id, @@ -247,8 +297,6 @@ export const proxyRouter = createTRPCRouter({ data: { description: input.description, isActive: input.isActive, - walletId: input.walletId, - userId: input.userId, }, }); }), @@ -261,15 +309,7 @@ export const proxyRouter = createTRPCRouter({ if (!proxy) { throw new TRPCError({ code: "NOT_FOUND", message: "Proxy not found" }); } - if (proxy.walletId) { - await assertWalletAccess(ctx, proxy.walletId, sessionAddress); - } - if (proxy.userId) { - const sessionUserId = await getUserIdForAddress(ctx, sessionAddress); - if (sessionUserId !== proxy.userId) { - throw new TRPCError({ code: "FORBIDDEN", message: "User mismatch" }); - } - } + await assertProxyManageAccess(ctx, proxy, sessionAddress); return ctx.db.proxy.delete({ where: { id: input.id, @@ -285,15 +325,7 @@ export const proxyRouter = createTRPCRouter({ if (!proxy) { throw new TRPCError({ code: "NOT_FOUND", message: "Proxy not found" }); } - if (proxy.walletId) { - await assertWalletAccess(ctx, proxy.walletId, sessionAddress); - } - if (proxy.userId) { - const sessionUserId = await getUserIdForAddress(ctx, sessionAddress); - if (sessionUserId !== proxy.userId) { - throw new TRPCError({ code: "FORBIDDEN", message: "User mismatch" }); - } - } + await assertProxyManageAccess(ctx, proxy, sessionAddress); return ctx.db.proxy.update({ where: { id: input.id, diff --git a/src/types/txSign.ts b/src/types/txSign.ts new file mode 100644 index 0000000..523a0c2 --- /dev/null +++ b/src/types/txSign.ts @@ -0,0 +1,33 @@ +import type { NativeScript } from "@meshsdk/core"; +import type { Wallet } from "@/types/wallet"; + +export type TxSubmitter = { + submitTx: (txHex: string) => Promise; +}; + +export type ScriptRecoveryWallet = Pick< + Wallet, + "type" | "numRequiredSigners" | "signersAddresses" | "scriptCbor" +> & { + rawImportBodies?: unknown; + nativeScript?: NativeScript; + address?: string; +}; + +export type SubmitTxWithRecoveryArgs = { + txHex: string; + submitter: TxSubmitter; + appWallet?: ScriptRecoveryWallet; + network?: number; +}; + +export type SubmitTxWithRecoveryResult = { + txHash: string; + txHex: string; + repaired: boolean; +}; + +export type MultisigSubmissionWallet = Pick< + ScriptRecoveryWallet, + "type" | "numRequiredSigners" | "signersAddresses" +>; \ No newline at end of file diff --git a/src/utils/common.ts b/src/utils/common.ts index 2e4c865..1be645a 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,5 +1,6 @@ import { DbWalletWithLegacy, Wallet } from "@/types/wallet"; import { + deserializeAddress, NativeScript, resolveNativeScriptHash, resolvePaymentKeyHash, @@ -10,11 +11,115 @@ import { } from "@meshsdk/core"; import { getDRepIds } from "@meshsdk/core-cst"; import { MultisigKey, MultisigWallet } from "@/utils/multisigSDK"; +import { + decodeNativeScriptFromCbor, + buildPaymentSigScriptsFromAddresses, + decodedToNativeScript, + normalizeHex, + scriptHashFromCbor, +} from "@/utils/nativeScriptUtils"; function addressToNetwork(address: string): number { return address.includes("test") ? 0 : 1; } +function resolveWalletNetwork(wallet: DbWalletWithLegacy, network?: number): number { + if (network !== undefined) { + return network; + } + + if (wallet.signersAddresses.length > 0) { + return addressToNetwork(wallet.signersAddresses[0]!); + } + + if (wallet.signersStakeKeys && wallet.signersStakeKeys.length > 0) { + const stakeAddr = wallet.signersStakeKeys.find((s) => !!s); + if (stakeAddr) { + return addressToNetwork(stakeAddr); + } + } + + // Default to mainnet when we cannot infer from stored addresses. + return 1; +} + +function buildPaymentSigScripts( + wallet: DbWalletWithLegacy, +): Array<{ type: "sig"; keyHash: string }> { + return buildPaymentSigScriptsFromAddresses( + wallet.signersAddresses, + (addr) => { + if (process.env.NODE_ENV === "development") { + console.warn("Invalid payment address in buildWallet:", addr); + } + }, + ); +} + +function buildNativeScriptFromPaymentSigners( + wallet: DbWalletWithLegacy, + validScripts: Array<{ type: "sig"; keyHash: string }>, +): NativeScript { + const nativeScript = { + type: (wallet.type as "all" | "any" | "atLeast") || "atLeast", + scripts: validScripts, + } as NativeScript; + if (nativeScript.type === "atLeast") { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Mesh NativeScript "atLeast" variant requires "required". + nativeScript.required = wallet.numRequiredSigners!; + } + return nativeScript; +} + +function buildDRepIdFromScript(nativeScript: NativeScript): string { + const dRepIdCip105 = resolveScriptHashDRepId( + resolveNativeScriptHash(nativeScript), + ); + const drepids = getDRepIds(dRepIdCip105); + return drepids.cip129; +} + +function resolveSummonScriptCbors(args: { + address: string; + paymentScript?: string | null; + stakeScript?: string | null; +}): { paymentScriptCbor?: string; stakeScriptCbor?: string } { + const paymentScript = args.paymentScript?.trim() || undefined; + const stakeScript = args.stakeScript?.trim() || undefined; + + if (!paymentScript && !stakeScript) { + return {}; + } + + const paymentScriptHash = scriptHashFromCbor(paymentScript); + const stakeScriptHash = scriptHashFromCbor(stakeScript); + + let addressScriptHash: string | undefined; + try { + const parsed = deserializeAddress(args.address) as { + scriptHash?: string; + scriptCredentialHash?: string; + }; + addressScriptHash = normalizeHex( + parsed.scriptHash || parsed.scriptCredentialHash, + ); + } catch { + addressScriptHash = undefined; + } + + if (addressScriptHash) { + if (paymentScriptHash === addressScriptHash) { + return { paymentScriptCbor: paymentScript, stakeScriptCbor: stakeScript }; + } + if (stakeScriptHash === addressScriptHash) { + return { paymentScriptCbor: stakeScript, stakeScriptCbor: paymentScript }; + } + } + + return { paymentScriptCbor: paymentScript, stakeScriptCbor: stakeScript }; +} + /** * Determines the wallet type based on its structure. * @@ -52,22 +157,7 @@ export function buildMultisigWallet( } const keys: MultisigKey[] = []; - - // Determine network from address if not provided - if (network === undefined) { - if (wallet.signersAddresses.length > 0) { - network = addressToNetwork(wallet.signersAddresses[0]!); - } else if (wallet.signersStakeKeys && wallet.signersStakeKeys.length > 0) { - const stakeAddr = wallet.signersStakeKeys.find((s) => !!s); - if (stakeAddr) { - network = addressToNetwork(stakeAddr); - } else { - network = 1; // Default to mainnet if we can't determine - } - } else { - network = 1; // Default to mainnet if we can't determine - } - } + const resolvedNetwork = resolveWalletNetwork(wallet, network); // Add payment keys (role 0) if (wallet.signersAddresses.length > 0) { @@ -131,30 +221,14 @@ export function buildMultisigWallet( ); return undefined; } - - // Ensure network is set - determine from address if not provided - if (network === undefined) { - if (wallet.signersAddresses.length > 0) { - network = addressToNetwork(wallet.signersAddresses[0]!); - } else if (wallet.signersStakeKeys && wallet.signersStakeKeys.length > 0) { - const stakeAddr = wallet.signersStakeKeys.find((s) => !!s); - if (stakeAddr) { - network = addressToNetwork(stakeAddr); - } else { - network = 1; // Default to mainnet if we can't determine - } - } else { - network = 1; // Default to mainnet if we can't determine - } - } - + const stakeCredentialHash = wallet.stakeCredentialHash as undefined | string; const multisigWallet = new MultisigWallet( wallet.name, keys, wallet.description ?? "", wallet.numRequiredSigners ?? 1, - network, + resolvedNetwork, stakeCredentialHash, (wallet.type as "all" | "any" | "atLeast") ?? "atLeast", ); @@ -192,28 +266,38 @@ export function buildWallet( throw new Error("rawImportBodies.multisig.address is required"); } - // Always use stored payment script from rawImportBodies - const scriptCbor = multisig.payment_script; + const { paymentScriptCbor, stakeScriptCbor } = resolveSummonScriptCbors({ + address, + paymentScript: multisig.payment_script, + stakeScript: multisig.stake_script, + }); + + // Always use the script that matches the address payment credential hash + const scriptCbor = paymentScriptCbor; if (!scriptCbor) { - throw new Error("rawImportBodies.multisig.payment_script is required"); + throw new Error("A valid payment script is required in rawImportBodies.multisig"); } - // Extract stake script from rawImportBodies - const stakeScriptCbor = multisig.stake_script; - - // For rawImportBodies wallets, we need a minimal nativeScript for type compatibility - // This won't be used for actual script derivation, but is required by the Wallet type + // Decode actual script structure from stored CBOR for display/inspection. + // The scriptCbor itself (used for address derivation and signing) remains unchanged. const scriptType = (wallet.type as "all" | "any" | "atLeast") ?? "atLeast"; - const nativeScript: NativeScript = scriptType === "atLeast" - ? { - type: "atLeast", - required: wallet.numRequiredSigners ?? 1, - scripts: [], - } - : { - type: scriptType, - scripts: [], - }; + let nativeScript: NativeScript; + try { + const decoded = decodeNativeScriptFromCbor(scriptCbor); + nativeScript = decodedToNativeScript(decoded); + } catch { + // Fallback to placeholder if decoding fails + nativeScript = scriptType === "atLeast" + ? { + type: "atLeast", + required: wallet.numRequiredSigners ?? 1, + scripts: [], + } + : { + type: scriptType, + scripts: [], + }; + } // For rawImportBodies wallets, dRepId cannot be easily derived from stored CBOR // Set to empty string - it can be derived later if needed from the actual script @@ -231,37 +315,14 @@ export function buildWallet( // Type 0 (Legacy): Build native script directly from payment keys in input order if (walletType === 'legacy') { - // Build native script from payment keys in exact input order - const validScripts = wallet.signersAddresses - .filter((addr) => addr) // Filter out null/undefined addresses - .map((addr) => { - try { - return { - type: "sig" as const, - keyHash: resolvePaymentKeyHash(addr!), - }; - } catch (e) { - if (process.env.NODE_ENV === "development") { - console.warn(`Invalid payment address in buildWallet:`, addr); - } - return null; - } - }) - .filter((script): script is { type: "sig"; keyHash: string } => script !== null); + const validScripts = buildPaymentSigScripts(wallet); if (validScripts.length === 0) { console.error("buildWallet: No valid payment addresses found"); throw new Error("Failed to build wallet: No valid payment addresses"); } - const nativeScript = { - type: (wallet.type as "all" | "any" | "atLeast") || "atLeast", - scripts: validScripts, - }; - if (nativeScript.type === "atLeast") { - //@ts-ignore - nativeScript.required = wallet.numRequiredSigners!; - } + const nativeScript = buildNativeScriptFromPaymentSigners(wallet, validScripts); // Build address from payment script with external stake credential hash if available // Legacy wallets can have external stake key hash but no individual stake keys @@ -271,12 +332,7 @@ export function buildWallet( network, ).address; - // Compute DRep ID from payment script hash - const dRepIdCip105 = resolveScriptHashDRepId( - resolveNativeScriptHash(nativeScript as NativeScript), - ); - const drepids = getDRepIds(dRepIdCip105); - const dRepIdCip129 = drepids.cip129; + const dRepIdCip129 = buildDRepIdFromScript(nativeScript); return { ...wallet, @@ -294,36 +350,14 @@ export function buildWallet( } // Build native script from payment keys for compatibility - const validScripts = wallet.signersAddresses - .filter((addr) => addr) - .map((addr) => { - try { - return { - type: "sig" as const, - keyHash: resolvePaymentKeyHash(addr!), - }; - } catch (e) { - if (process.env.NODE_ENV === "development") { - console.warn(`Invalid payment address in buildWallet:`, addr); - } - return null; - } - }) - .filter((script): script is { type: "sig"; keyHash: string } => script !== null); + const validScripts = buildPaymentSigScripts(wallet); if (validScripts.length === 0) { console.error("buildWallet: No valid payment addresses found"); throw new Error("Failed to build wallet: No valid payment addresses"); } - const nativeScript = { - type: (wallet.type as "all" | "any" | "atLeast") || "atLeast", - scripts: validScripts, - }; - if (nativeScript.type === "atLeast") { - //@ts-ignore - nativeScript.required = wallet.numRequiredSigners!; - } + const nativeScript = buildNativeScriptFromPaymentSigners(wallet, validScripts); // Use SDK address (prefer stakeable address if staking is enabled) const paymentAddress = serializeNativeScript( @@ -342,11 +376,7 @@ export function buildWallet( } // Compute DRep ID from payment script hash (SDK can override this via getDRepId) - const dRepIdCip105 = resolveScriptHashDRepId( - resolveNativeScriptHash(nativeScript as NativeScript), - ); - const drepids = getDRepIds(dRepIdCip105); - const dRepIdCip129 = drepids.cip129; + const dRepIdCip129 = buildDRepIdFromScript(nativeScript); return { ...wallet, diff --git a/src/utils/get-tx-builder.ts b/src/utils/get-tx-builder.ts index 7c956f2..a736513 100644 --- a/src/utils/get-tx-builder.ts +++ b/src/utils/get-tx-builder.ts @@ -1,5 +1,6 @@ import { MeshTxBuilder } from "@meshsdk/core"; import { getProvider } from "@/utils/get-provider"; +import { STAKE_KEY_DEPOSIT } from "@/utils/protocol-deposit-constants"; // import { CSLSerializer } from "@meshsdk/core-csl"; export function getTxBuilder(network: number) { @@ -7,6 +8,11 @@ export function getTxBuilder(network: number) { const txBuilder = new MeshTxBuilder({ fetcher: blockchainProvider, evaluator: blockchainProvider, + params: { + // Explicitly provide stake key deposit so certificate balancing + // remains deterministic even when fetcher protocol params vary. + keyDeposit: STAKE_KEY_DEPOSIT, + }, // serializer: new CSLSerializer(), verbose: true, }); diff --git a/src/utils/nativeScriptUtils.ts b/src/utils/nativeScriptUtils.ts new file mode 100644 index 0000000..7af0877 --- /dev/null +++ b/src/utils/nativeScriptUtils.ts @@ -0,0 +1,320 @@ +import type { NativeScript } from "@meshsdk/core"; +import { resolvePaymentKeyHash } from "@meshsdk/core"; +import type { csl } from "@meshsdk/core-csl"; +import { deserializeNativeScript } from "@meshsdk/core-csl"; + +// --- Decoded native script types --- + +export type DecodedNativeScript = + | { type: "sig"; keyHash: string } + | { type: "all"; scripts: DecodedNativeScript[] } + | { type: "any"; scripts: DecodedNativeScript[] } + | { type: "atLeast"; required: number; scripts: DecodedNativeScript[] } + | { type: "timelockStart"; slot: string } + | { type: "timelockExpiry"; slot: string }; + +export type SigMatch = { + sigKeyHash: string; + matched: boolean; + matchedBy?: "paymentAddress" | "stakeKey"; + signerIndex?: number; + signerAddress?: string; + signerStakeKey?: string; +}; + +export type PaymentSigScript = { type: "sig"; keyHash: string }; + +export function buildPaymentSigScriptsFromAddresses( + signersAddresses: Array, + onInvalidAddress?: (address: string) => void, +): PaymentSigScript[] { + return signersAddresses + .filter((addr): addr is string => !!addr) + .map((addr) => { + try { + return { + type: "sig" as const, + keyHash: resolvePaymentKeyHash(addr), + }; + } catch { + onInvalidAddress?.(addr); + return null; + } + }) + .filter((script): script is PaymentSigScript => script !== null); +} + +// --- CBOR normalization --- + +export function normalizeCborHex(cborHex: string): string { + const trimmed = (cborHex || "").trim(); + if (trimmed.startsWith("0x") || trimmed.startsWith("0X")) { + return trimmed.slice(2); + } + return trimmed; +} + +export function normalizeHex(value?: string | null): string | undefined { + if (!value) return undefined; + return value.trim().toLowerCase().replace(/^0x/, ""); +} + +export function scriptHashFromCbor(cborHex?: string | null): string | undefined { + if (!cborHex?.trim()) return undefined; + try { + const script = deserializeNativeScript(normalizeCborHex(cborHex)); + return normalizeHex(script.hash().to_hex()); + } catch { + return undefined; + } +} + +// --- Decoding from CBOR / CSL --- + +export function decodeNativeScriptFromCbor( + cborHex: string, +): DecodedNativeScript { + const ns = deserializeNativeScript(normalizeCborHex(cborHex)); + return decodeNativeScriptFromCsl(ns); +} + +export function decodeNativeScriptFromCsl( + ns: csl.NativeScript, +): DecodedNativeScript { + const sp = ns.as_script_pubkey(); + if (sp) { + const keyHash = sp.addr_keyhash().to_hex(); + return { type: "sig", keyHash }; + } + + const tls = ns.as_timelock_start?.(); + if (tls) { + const slot = String(tls.slot_bignum?.().to_str?.() ?? tls.slot?.() ?? "0"); + return { type: "timelockStart", slot }; + } + + const tle = ns.as_timelock_expiry?.(); + if (tle) { + const slot = String(tle.slot_bignum?.().to_str?.() ?? tle.slot?.() ?? "0"); + return { type: "timelockExpiry", slot }; + } + + const saAll = ns.as_script_all(); + if (saAll) { + const list = saAll.native_scripts(); + const scripts: DecodedNativeScript[] = []; + for (let i = 0; i < list.len(); i++) { + const child = list.get(i); + scripts.push(decodeNativeScriptFromCsl(child)); + } + return { type: "all", scripts }; + } + + const saAny = ns.as_script_any(); + if (saAny) { + const list = saAny.native_scripts(); + const scripts: DecodedNativeScript[] = []; + for (let i = 0; i < list.len(); i++) { + const child = list.get(i); + scripts.push(decodeNativeScriptFromCsl(child)); + } + return { type: "any", scripts }; + } + + const sn = ns.as_script_n_of_k(); + if (sn) { + const list = sn.native_scripts(); + const scripts: DecodedNativeScript[] = []; + for (let i = 0; i < list.len(); i++) { + const child = list.get(i); + scripts.push(decodeNativeScriptFromCsl(child)); + } + const n = sn.n(); + const required = + typeof n === "number" + ? n + : Number( + (n as unknown as { to_str?: () => string }).to_str?.() ?? + (n as unknown as number), + ); + return { type: "atLeast", required, scripts }; + } + + // Unknown variant; default to requiring 1 signature + return { type: "atLeast", required: 1, scripts: [] }; +} + +// --- Conversion: DecodedNativeScript -> @meshsdk/core NativeScript --- + +export function decodedToNativeScript( + decoded: DecodedNativeScript, +): NativeScript { + switch (decoded.type) { + case "sig": + return { type: "sig", keyHash: decoded.keyHash }; + case "all": + return { + type: "all", + scripts: decoded.scripts.map(decodedToNativeScript), + }; + case "any": + return { + type: "any", + scripts: decoded.scripts.map(decodedToNativeScript), + }; + case "atLeast": + return { + type: "atLeast", + required: decoded.required, + scripts: decoded.scripts.map(decodedToNativeScript), + }; + case "timelockStart": + return { type: "after", slot: decoded.slot }; + case "timelockExpiry": + return { type: "before", slot: decoded.slot }; + default: + return { type: "all", scripts: [] }; + } +} + +// --- Script analysis utilities --- + +/** Collect all sig key hashes from a decoded native script tree (deduplicated). */ +export function collectSigKeyHashes(node: DecodedNativeScript): string[] { + if (node.type === "sig") return [node.keyHash.toLowerCase()]; + if (node.type === "all" || node.type === "any" || node.type === "atLeast") { + const out: string[] = []; + for (const child of node.scripts) out.push(...collectSigKeyHashes(child)); + return Array.from(new Set(out)); + } + return []; +} + +/** + * A script is considered hierarchical ONLY if some signature node is nested + * under two or more logical groups (all/any/atLeast). + */ +export function isHierarchicalScript(script: DecodedNativeScript): boolean { + return hasSigWithLogicalDepth(script, 0); +} + +function hasSigWithLogicalDepth( + node: DecodedNativeScript, + logicalDepth: number, +): boolean { + if (node.type === "sig") { + return logicalDepth >= 2; + } + if (node.type === "all" || node.type === "any" || node.type === "atLeast") { + for (const child of node.scripts) { + if (hasSigWithLogicalDepth(child, logicalDepth + 1)) return true; + } + return false; + } + return false; +} + +/** Compute the minimum number of required signers to satisfy the script. */ +export function computeRequiredSigners(script: DecodedNativeScript): number { + switch (script.type) { + case "sig": + return 1; + case "timelockStart": + case "timelockExpiry": + return 0; + case "any": + if (script.scripts.length === 0) return 0; + return Math.min(...script.scripts.map((s) => computeRequiredSigners(s))); + case "all": { + let total = 0; + for (const s of script.scripts) total += computeRequiredSigners(s); + return total; + } + case "atLeast": { + if (script.scripts.length === 0) + return Math.max(0, script.required); + const childReqs = script.scripts + .map((s) => computeRequiredSigners(s)) + .sort((a, b) => a - b); + const need = Math.max( + 0, + Math.min(script.required, childReqs.length), + ); + let sum = 0; + for (let i = 0; i < need; i++) sum += childReqs[i]!; + return sum; + } + default: + return 0; + } +} + +/** + * Returns the signature rule type by inspecting parents of "sig" leaves: + * - If any sig's parent is "atLeast", return "atLeast" + * - Else if any sig's parent is "all", return "all" + * - Else if any sig's parent is "any", return "any" + * - Else fall back to the root type or "atLeast" + */ +export function detectTypeFromSigParents( + script: DecodedNativeScript, +): "all" | "any" | "atLeast" { + const parentTypes = new Set<"all" | "any" | "atLeast">(); + collectSigParentTypes(script, null, parentTypes); + if (parentTypes.has("atLeast")) return "atLeast"; + if (parentTypes.has("all")) return "all"; + if (parentTypes.has("any")) return "any"; + if (script.type === "all" || script.type === "any") return script.type; + return "atLeast"; +} + +function collectSigParentTypes( + node: DecodedNativeScript, + parentType: "all" | "any" | "atLeast" | null, + out: Set<"all" | "any" | "atLeast">, +): void { + if (node.type === "sig") { + if (parentType) out.add(parentType); + return; + } + if (node.type === "all" || node.type === "any" || node.type === "atLeast") { + for (const child of node.scripts) { + collectSigParentTypes(child, node.type, out); + } + return; + } + // timelock nodes: nothing to traverse further +} + +/** Count total sig leaves in the tree (including duplicates). */ +export function countTotalSigs(script: DecodedNativeScript): number { + if (script.type === "sig") return 1; + if ( + script.type === "all" || + script.type === "any" || + script.type === "atLeast" + ) { + let total = 0; + for (const child of script.scripts) total += countTotalSigs(child); + return total; + } + return 0; +} + +/** Returns the max nesting depth of logical groups. */ +export function getScriptDepth(script: DecodedNativeScript): number { + if ( + script.type === "all" || + script.type === "any" || + script.type === "atLeast" + ) { + if (script.scripts.length === 0) return 1; + let maxChild = 0; + for (const child of script.scripts) { + const d = getScriptDepth(child); + if (d > maxChild) maxChild = d; + } + return 1 + maxChild; + } + return 0; +} diff --git a/src/utils/protocol-deposit-constants.ts b/src/utils/protocol-deposit-constants.ts new file mode 100644 index 0000000..7b7a331 --- /dev/null +++ b/src/utils/protocol-deposit-constants.ts @@ -0,0 +1,5 @@ +export const STAKE_KEY_DEPOSIT = 2_000_000; +export const STAKE_KEY_DEPOSIT_LOVELACE = 2_000_000n; +export const DREP_DEPOSIT = 500_000_000; +export const DREP_DEPOSIT_LOVELACE = 500_000_000n; +export const DREP_DEPOSIT_STRING = "500000000"; diff --git a/src/utils/txScriptRecovery.ts b/src/utils/txScriptRecovery.ts new file mode 100644 index 0000000..07e4c43 --- /dev/null +++ b/src/utils/txScriptRecovery.ts @@ -0,0 +1,483 @@ +import type { NativeScript } from "@meshsdk/core"; +import { + deserializeAddress, + serializeNativeScript, +} from "@meshsdk/core"; +import { csl, deserializeNativeScript } from "@meshsdk/core-csl"; +import type { + MultisigSubmissionWallet, + ScriptRecoveryWallet, + SubmitTxWithRecoveryArgs, + SubmitTxWithRecoveryResult, +} from "@/types/txSign"; +import { + buildPaymentSigScriptsFromAddresses, + normalizeHex, + scriptHashFromCbor, +} from "@/utils/nativeScriptUtils"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function resolveMultisigScripts(rawImportBodies: unknown): { + paymentScript?: string; + stakeScript?: string; +} { + if (!isRecord(rawImportBodies)) { + return {}; + } + + const multisig = rawImportBodies.multisig; + if (!isRecord(multisig)) { + return {}; + } + + const paymentScript = typeof multisig.payment_script === "string" + ? multisig.payment_script + : undefined; + const stakeScript = typeof multisig.stake_script === "string" + ? multisig.stake_script + : undefined; + + return { paymentScript, stakeScript }; +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + .toLowerCase(); +} + +function extractErrorMessage(error: unknown): string { + const e = error as { response?: { data?: { message?: string } }; message?: string }; + const message = e?.response?.data?.message || e?.message || JSON.stringify(error || ""); + return typeof message === "string" ? message : String(message); +} + +export function getFirstNativeScriptCborFromTx(txHex: string): string | undefined { + try { + const tx = csl.Transaction.from_hex(txHex); + const nativeScripts = tx.witness_set().native_scripts(); + if (!nativeScripts || nativeScripts.len() === 0) return undefined; + return bytesToHex(nativeScripts.get(0).to_bytes()); + } catch { + return undefined; + } +} + +function getNativeScriptWitnessCbors(txHex: string): string[] { + try { + const tx = csl.Transaction.from_hex(txHex); + const nativeScripts = tx.witness_set().native_scripts(); + if (!nativeScripts || nativeScripts.len() === 0) return []; + + const scripts: string[] = []; + for (let i = 0; i < nativeScripts.len(); i++) { + scripts.push(bytesToHex(nativeScripts.get(i).to_bytes())); + } + return scripts; + } catch { + return []; + } +} + +export function replaceNativeScriptWitness(txHex: string, scriptCbor: string): string { + return setNativeScriptWitnesses(txHex, [scriptCbor]); +} + +function dedupeScriptSetByHash(scriptCbors: string[]): string[] { + const uniqueScripts: string[] = []; + const seenHashes = new Set(); + const seenCbors = new Set(); + + for (const scriptCbor of scriptCbors) { + const trimmed = scriptCbor.trim(); + if (!trimmed) continue; + + const normalizedCbor = trimmed.toLowerCase(); + if (seenCbors.has(normalizedCbor)) continue; + + const scriptHash = scriptHashFromCbor(trimmed); + if (scriptHash && seenHashes.has(scriptHash)) continue; + + seenCbors.add(normalizedCbor); + if (scriptHash) seenHashes.add(scriptHash); + uniqueScripts.push(trimmed); + } + + return uniqueScripts; +} + +function setNativeScriptWitnesses(txHex: string, scriptCbors: string[]): string { + const tx = csl.Transaction.from_hex(txHex); + const txBodyClone = csl.TransactionBody.from_bytes(tx.body().to_bytes()); + const witnessSetClone = csl.TransactionWitnessSet.from_bytes( + tx.witness_set().to_bytes(), + ); + + const nativeScripts = csl.NativeScripts.new(); + const uniqueScripts = dedupeScriptSetByHash(scriptCbors); + for (const scriptCbor of uniqueScripts) { + const canonicalScript = deserializeNativeScript(scriptCbor); + nativeScripts.add(canonicalScript); + } + witnessSetClone.set_native_scripts(nativeScripts); + + const rebuiltTx = csl.Transaction.new( + txBodyClone, + witnessSetClone, + tx.auxiliary_data(), + ); + if (!tx.is_valid()) { + rebuiltTx.set_is_valid(false); + } + + return rebuiltTx.to_hex(); +} + +export function extractMissingScriptHashFromError(error: unknown): string | undefined { + const hashes = extractMissingScriptHashesFromError(error); + return hashes[0]; +} + +function extractScriptHashesFromFailureList( + message: string, + failureType: "MissingScriptWitnessesUTXOW" | "ExtraneousScriptWitnessesUTXOW", +): string[] { + const markerIndex = message.indexOf(failureType); + if (markerIndex < 0) return []; + + const tail = message.slice(markerIndex); + const listMatch = tail.match( + new RegExp(`${failureType}\\s*\\(fromList\\s*\\[([^\\]]*)\\]\\)`), + ); + if (!listMatch?.[1]) return []; + + const hashes = listMatch[1].match(/[0-9a-fA-F]{56}/g) || []; + return Array.from(new Set(hashes.map((hash) => hash.toLowerCase()))); +} + +function extractMissingScriptHashesFromError(error: unknown): string[] { + const message = extractErrorMessage(error); + const hashes = extractScriptHashesFromFailureList(message, "MissingScriptWitnessesUTXOW"); + if (hashes.length > 0) { + return hashes; + } + + const fallbackMatch = message.match(/([0-9a-fA-F]{56})/); + return fallbackMatch?.[1] ? [fallbackMatch[1].toLowerCase()] : []; +} + +function extractExtraneousScriptHashesFromError(error: unknown): string[] { + const message = extractErrorMessage(error); + return extractScriptHashesFromFailureList(message, "ExtraneousScriptWitnessesUTXOW"); +} + +function hasMissingScriptWitnessFailure(error: unknown): boolean { + return extractErrorMessage(error).includes("MissingScriptWitnessesUTXOW"); +} + +function hasIrrecoverableInputFailure(error: unknown): boolean { + const message = extractErrorMessage(error); + return message.includes("BadInputsUTxO"); +} + +function hasValueNotConservedFailure(error: unknown): boolean { + return extractErrorMessage(error).includes("ValueNotConservedUTxO"); +} + +function buildStaleInputError(error: unknown): Error { + const original = extractErrorMessage(error); + return new Error( + "Transaction inputs are no longer available on chain. Please rebuild and re-collect signatures for this transaction. " + + `Original submit error: ${original}`, + ); +} + +function buildValueNotConservedError(error: unknown): Error { + const original = extractErrorMessage(error); + return new Error( + "Transaction value is not balanced (ValueNotConservedUTxO). This usually means inputs do not cover outputs + fees + deposits (for example, stake registration deposit). " + + "Please rebuild the transaction with sufficient ADA inputs and re-collect signatures. " + + `Original submit error: ${original}`, + ); +} + +export function buildLegacyStylePaymentScriptCbor( + appWallet: ScriptRecoveryWallet, + network: number, +): string | undefined { + if (!appWallet?.signersAddresses?.length) return undefined; + try { + const scripts = buildPaymentSigScriptsFromAddresses(appWallet.signersAddresses); + + if (scripts.length === 0) return undefined; + + const script: NativeScript = + appWallet.type === "atLeast" + ? { + type: "atLeast", + required: appWallet.numRequiredSigners ?? 1, + scripts, + } + : { + type: appWallet.type as "all" | "any", + scripts, + }; + + return serializeNativeScript(script, undefined, network, true).scriptCbor; + } catch { + return undefined; + } +} + +export function buildSerializedNativeScriptCbor( + appWallet: ScriptRecoveryWallet, + network: number, +): string | undefined { + if (!appWallet?.nativeScript) return undefined; + try { + return serializeNativeScript(appWallet.nativeScript, undefined, network, true) + .scriptCbor; + } catch { + return undefined; + } +} + +export function resolveExpectedPaymentScriptCbor( + appWallet: ScriptRecoveryWallet, +): string | undefined { + const { paymentScript, stakeScript } = resolveMultisigScripts( + appWallet.rawImportBodies, + ); + const candidatePayment = paymentScript?.trim(); + const candidateStake = stakeScript?.trim(); + + let addressScriptHash: string | undefined; + try { + if (!appWallet.address) { + throw new Error("Missing wallet address"); + } + const parsed = deserializeAddress(appWallet.address) as { + scriptHash?: string; + scriptCredentialHash?: string; + }; + addressScriptHash = normalizeHex( + parsed.scriptHash || parsed.scriptCredentialHash, + ); + } catch { + addressScriptHash = undefined; + } + + const paymentHash = scriptHashFromCbor(candidatePayment); + const stakeHash = scriptHashFromCbor(candidateStake); + const walletScriptHash = scriptHashFromCbor(appWallet.scriptCbor); + + if (addressScriptHash) { + if (paymentHash === addressScriptHash && candidatePayment) { + return candidatePayment; + } + if (stakeHash === addressScriptHash && candidateStake) { + return candidateStake; + } + if (walletScriptHash === addressScriptHash && appWallet.scriptCbor) { + return appWallet.scriptCbor; + } + } + + return appWallet.scriptCbor; +} + +function findCandidateScriptByHash( + appWallet: ScriptRecoveryWallet, + targetHash?: string, +): string | undefined { + if (!targetHash) return undefined; + + const { paymentScript, stakeScript } = resolveMultisigScripts( + appWallet.rawImportBodies, + ); + + const candidates = [ + appWallet.scriptCbor, + paymentScript, + stakeScript, + ].filter((value): value is string => !!value && value.trim().length > 0); + + for (const candidate of candidates) { + if (scriptHashFromCbor(candidate) === targetHash) { + return candidate; + } + } + + return undefined; +} + +function dedupeScriptCbors(candidates: Array): string[] { + const seenCbors = new Set(); + const uniqueCandidates: string[] = []; + + for (const candidate of candidates) { + if (!candidate || candidate.trim().length === 0) continue; + const trimmed = candidate.trim(); + const normalized = trimmed.toLowerCase(); + if (seenCbors.has(normalized)) continue; + seenCbors.add(normalized); + uniqueCandidates.push(trimmed); + } + + return uniqueCandidates; +} + +export function shouldSubmitMultisigTx( + appWallet: MultisigSubmissionWallet, + signedAddressesCount: number, +): boolean { + if (appWallet.type === "any") { + return signedAddressesCount >= 1; + } + if (appWallet.type === "atLeast") { + const required = appWallet.numRequiredSigners ?? 1; + return signedAddressesCount >= required; + } + return signedAddressesCount >= appWallet.signersAddresses.length; +} + +function throwIfUnrecoverableSubmitError(error: unknown): void { + if (hasIrrecoverableInputFailure(error)) { + throw buildStaleInputError(error); + } + if (hasValueNotConservedFailure(error)) { + throw buildValueNotConservedError(error); + } +} + +function addCandidateScriptSet( + candidateScriptSets: Map, + scripts: Array, +): void { + const normalized = dedupeScriptSetByHash( + scripts.filter((value): value is string => !!value), + ); + if (normalized.length === 0) return; + + const key = normalized.map((script) => scriptHashFromCbor(script) ?? script).join("|"); + if (!candidateScriptSets.has(key)) { + candidateScriptSets.set(key, normalized); + } +} + +function buildRecoveryCandidateScriptSets( + txHex: string, + appWallet: ScriptRecoveryWallet, + network: number, + missingScriptHashes: string[], + extraneousScriptHashes: Set, +): Map { + const preferredScript = findCandidateScriptByHash(appWallet, missingScriptHashes[0]); + const { paymentScript, stakeScript } = resolveMultisigScripts( + appWallet.rawImportBodies, + ); + const expectedScript = resolveExpectedPaymentScriptCbor(appWallet); + + const currentWitnessScripts = getNativeScriptWitnessCbors(txHex); + const retainedCurrentScripts = currentWitnessScripts.filter((scriptCbor) => { + const hash = scriptHashFromCbor(scriptCbor); + if (!hash) return true; + return !extraneousScriptHashes.has(hash); + }); + + const missingScripts = missingScriptHashes + .map((hash) => findCandidateScriptByHash(appWallet, hash)) + .filter((value): value is string => !!value); + + const candidateScripts = dedupeScriptCbors([ + preferredScript, + expectedScript, + paymentScript, + stakeScript, + buildSerializedNativeScriptCbor(appWallet, network), + buildLegacyStylePaymentScriptCbor(appWallet, network), + appWallet.scriptCbor, + ]); + + const candidateScriptSets = new Map(); + addCandidateScriptSet(candidateScriptSets, missingScripts); + addCandidateScriptSet(candidateScriptSets, [...retainedCurrentScripts, ...missingScripts]); + addCandidateScriptSet(candidateScriptSets, [paymentScript, stakeScript]); + addCandidateScriptSet(candidateScriptSets, [expectedScript]); + addCandidateScriptSet(candidateScriptSets, [preferredScript]); + addCandidateScriptSet(candidateScriptSets, [appWallet.scriptCbor]); + + for (const candidate of candidateScripts) { + addCandidateScriptSet(candidateScriptSets, [candidate]); + } + + return candidateScriptSets; +} + +async function retrySubmitWithCandidateScriptSets( + txHex: string, + submitter: SubmitTxWithRecoveryArgs["submitter"], + candidateScriptSets: Map, + initialError: unknown, +): Promise { + let lastRetryError: unknown = initialError; + + for (const scriptSet of candidateScriptSets.values()) { + const repairedTx = setNativeScriptWitnesses(txHex, scriptSet); + try { + const txHash = await submitter.submitTx(repairedTx); + return { txHash, txHex: repairedTx, repaired: true }; + } catch (retryError) { + lastRetryError = retryError; + } + } + + throw lastRetryError; +} + +export async function submitTxWithScriptRecovery({ + txHex, + submitter, + appWallet, + network, +}: SubmitTxWithRecoveryArgs): Promise { + try { + const txHash = await submitter.submitTx(txHex); + return { txHash, txHex, repaired: false }; + } catch (submitError) { + throwIfUnrecoverableSubmitError(submitError); + + if (!appWallet || network === undefined) { + throw submitError; + } + + if (!hasMissingScriptWitnessFailure(submitError)) { + throw submitError; + } + + const missingScriptHashes = extractMissingScriptHashesFromError(submitError); + const extraneousScriptHashes = new Set(extractExtraneousScriptHashesFromError(submitError)); + const candidateScriptSets = buildRecoveryCandidateScriptSets( + txHex, + appWallet, + network, + missingScriptHashes, + extraneousScriptHashes, + ); + + if (candidateScriptSets.size === 0) { + throw submitError; + } + + return retrySubmitWithCandidateScriptSets( + txHex, + submitter, + candidateScriptSets, + submitError, + ); + } +} diff --git a/src/utils/txSignUtils.ts b/src/utils/txSignUtils.ts new file mode 100644 index 0000000..a856c7e --- /dev/null +++ b/src/utils/txSignUtils.ts @@ -0,0 +1,169 @@ +import { csl } from "@meshsdk/core-csl"; + +function toKeyHashHex(publicKey: csl.PublicKey): string { + return Array.from(publicKey.hash().to_bytes()) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + .toLowerCase(); +} + +function cloneVkeyWitnesses( + witnessSet: csl.TransactionWitnessSet, +): csl.Vkeywitnesses { + const existingVkeys = witnessSet.vkeys(); + if (!existingVkeys) { + return csl.Vkeywitnesses.new(); + } + return csl.Vkeywitnesses.from_bytes(existingVkeys.to_bytes()); +} + +function mergeUniqueWitnesses( + targetVkeys: csl.Vkeywitnesses, + incomingVkeys: csl.Vkeywitnesses, +): { mergedVkeys: csl.Vkeywitnesses; addedCount: number } { + const existingKeyHashes = new Set(); + const existingVkeyCount = targetVkeys.len(); + for (let i = 0; i < existingVkeyCount; i++) { + const existingWitness = targetVkeys.get(i); + existingKeyHashes.add(toKeyHashHex(existingWitness.vkey().public_key())); + } + + let addedCount = 0; + const incomingVkeyCount = incomingVkeys.len(); + for (let i = 0; i < incomingVkeyCount; i++) { + const incomingWitness = incomingVkeys.get(i); + const incomingKeyHash = toKeyHashHex(incomingWitness.vkey().public_key()); + if (!existingKeyHashes.has(incomingKeyHash)) { + targetVkeys.add(incomingWitness); + existingKeyHashes.add(incomingKeyHash); + addedCount += 1; + } + } + + return { mergedVkeys: targetVkeys, addedCount }; +} + +export function extractVkeyWitnesses(signedPayloadHex: string): csl.Vkeywitnesses { + try { + const signedTx = csl.Transaction.from_hex(signedPayloadHex); + const txVkeys = signedTx.witness_set().vkeys(); + if (!txVkeys) { + return csl.Vkeywitnesses.new(); + } + return csl.Vkeywitnesses.from_bytes(txVkeys.to_bytes()); + } catch { + const witnessSet = csl.TransactionWitnessSet.from_hex(signedPayloadHex); + const witnessVkeys = witnessSet.vkeys(); + if (!witnessVkeys) { + return csl.Vkeywitnesses.new(); + } + return csl.Vkeywitnesses.from_bytes(witnessVkeys.to_bytes()); + } +} + +export function createVkeyWitnessFromHex( + keyHex: string, + signatureHex: string, +): { + publicKey: csl.PublicKey; + signature: csl.Ed25519Signature; + witness: csl.Vkeywitness; + keyHashHex: string; +} { + const publicKey = csl.PublicKey.from_hex(keyHex); + const signature = csl.Ed25519Signature.from_hex(signatureHex); + const vkey = csl.Vkey.new(publicKey); + const witness = csl.Vkeywitness.new(vkey, signature); + + return { + publicKey, + signature, + witness, + keyHashHex: toKeyHashHex(publicKey), + }; +} + +export function addUniqueVkeyWitnessToTx( + originalTxHex: string, + witnessToAdd: csl.Vkeywitness, +): { + txHex: string; + witnessAdded: boolean; + vkeyWitnesses: csl.Vkeywitnesses; +} { + const originalTx = csl.Transaction.from_hex(originalTxHex); + const txBodyClone = csl.TransactionBody.from_bytes(originalTx.body().to_bytes()); + const witnessSetClone = csl.TransactionWitnessSet.from_bytes( + originalTx.witness_set().to_bytes(), + ); + + const vkeyWitnesses = cloneVkeyWitnesses(witnessSetClone); + const incoming = csl.Vkeywitnesses.new(); + incoming.add(witnessToAdd); + + const { addedCount } = mergeUniqueWitnesses(vkeyWitnesses, incoming); + if (addedCount === 0) { + return { + txHex: originalTxHex, + witnessAdded: false, + vkeyWitnesses, + }; + } + + witnessSetClone.set_vkeys(vkeyWitnesses); + + const updatedTx = csl.Transaction.new( + txBodyClone, + witnessSetClone, + originalTx.auxiliary_data(), + ); + if (!originalTx.is_valid()) { + updatedTx.set_is_valid(false); + } + + return { + txHex: updatedTx.to_hex(), + witnessAdded: true, + vkeyWitnesses, + }; +} + +export function mergeSignerWitnesses( + originalTxHex: string, + signedPayloadHex: string, +): string { + const originalTx = csl.Transaction.from_hex(originalTxHex); + const txBodyClone = csl.TransactionBody.from_bytes(originalTx.body().to_bytes()); + const witnessSetClone = csl.TransactionWitnessSet.from_bytes( + originalTx.witness_set().to_bytes(), + ); + + const mergedVkeys = cloneVkeyWitnesses(witnessSetClone); + + const incomingVkeys = extractVkeyWitnesses(signedPayloadHex); + mergeUniqueWitnesses(mergedVkeys, incomingVkeys); + + witnessSetClone.set_vkeys(mergedVkeys); + + const mergedTx = csl.Transaction.new( + txBodyClone, + witnessSetClone, + originalTx.auxiliary_data(), + ); + if (!originalTx.is_valid()) { + mergedTx.set_is_valid(false); + } + + return mergedTx.to_hex(); +} + +export { + buildLegacyStylePaymentScriptCbor, + buildSerializedNativeScriptCbor, + extractMissingScriptHashFromError, + getFirstNativeScriptCborFromTx, + replaceNativeScriptWitness, + resolveExpectedPaymentScriptCbor, + shouldSubmitMultisigTx, + submitTxWithScriptRecovery, +} from "@/utils/txScriptRecovery"; diff --git a/src/utils/validateMultisigImport.ts b/src/utils/validateMultisigImport.ts index 90ca33c..85eac83 100644 --- a/src/utils/validateMultisigImport.ts +++ b/src/utils/validateMultisigImport.ts @@ -1,8 +1,15 @@ import { checkValidAddress, addressToNetwork, stakeKeyHash, paymentKeyHash } from "@/utils/multisigSDK"; -import { serializeRewardAddress, pubKeyAddress } from "@meshsdk/core"; -import type { csl } from "@meshsdk/core-csl"; -import { deserializeNativeScript } from "@meshsdk/core-csl"; +import { serializeRewardAddress } from "@meshsdk/core"; import { getProvider } from "@/utils/get-provider"; +import { + type SigMatch, + normalizeCborHex, + decodeNativeScriptFromCbor, + collectSigKeyHashes, + isHierarchicalScript, + computeRequiredSigners, + detectTypeFromSigParents, +} from "@/utils/nativeScriptUtils"; export type ImportedMultisigRow = { multisig_id?: string; @@ -397,7 +404,8 @@ export async function validateMultisigImportPayload(payload: unknown): Promise(); - collectSigParentTypes(script, null, parentTypes); - if (parentTypes.has("atLeast")) return "atLeast"; - if (parentTypes.has("all")) return "all"; - if (parentTypes.has("any")) return "any"; - if (script.type === "all" || script.type === "any") return script.type; - return "atLeast"; -} - -function collectSigParentTypes( - node: DecodedNativeScript, - parentType: "all" | "any" | "atLeast" | null, - out: Set<"all" | "any" | "atLeast"> -): void { - if (node.type === "sig") { - if (parentType) out.add(parentType); - return; - } - if (node.type === "all" || node.type === "any" || node.type === "atLeast") { - for (const child of node.scripts) { - collectSigParentTypes(child, node.type, out); - } - return; - } - // timelock nodes: nothing to traverse further -} +// detectTypeFromSigParents is now imported from @/utils/nativeScriptUtils function buildStakeAddressesFromHashes(stakeKeys: string[], network: number | undefined): string[] { const out: string[] = []; @@ -607,156 +584,7 @@ function buildStakeAddressesFromHashes(stakeKeys: string[], network: number | un return Array.from(new Set(out)); } -// --- Native script decoding helpers --- - -type DecodedNativeScript = - | { type: "sig"; keyHash: string } - | { type: "all"; scripts: DecodedNativeScript[] } - | { type: "any"; scripts: DecodedNativeScript[] } - | { type: "atLeast"; required: number; scripts: DecodedNativeScript[] } - | { type: "timelockStart" } - | { type: "timelockExpiry" }; - -type SigMatch = { - sigKeyHash: string; - matched: boolean; - matchedBy?: "paymentAddress" | "stakeKey"; - signerIndex?: number; - signerAddress?: string; - signerStakeKey?: string; -}; - -function normalizeCborHex(cborHex: string): string { - const trimmed = (cborHex || "").trim(); - if (trimmed.startsWith("0x") || trimmed.startsWith("0X")) { - return trimmed.slice(2); - } - return trimmed; -} - -function decodeNativeScriptFromCbor(cborHex: string): DecodedNativeScript { - const ns = deserializeNativeScript(normalizeCborHex(cborHex)); - return decodeNativeScriptFromCsl(ns); -} - -function decodeNativeScriptFromCsl(ns: csl.NativeScript): DecodedNativeScript { - const sp = ns.as_script_pubkey(); - if (sp) { - const keyHash = sp.addr_keyhash().to_hex(); - return { type: "sig", keyHash }; - } - - const tls = ns.as_timelock_start?.(); - if (tls) { - return { type: "timelockStart" }; - } - - const tle = ns.as_timelock_expiry?.(); - if (tle) { - return { type: "timelockExpiry" }; - } - - const saAll = ns.as_script_all(); - if (saAll) { - const list = saAll.native_scripts(); - const scripts: DecodedNativeScript[] = []; - for (let i = 0; i < list.len(); i++) { - const child = list.get(i); - scripts.push(decodeNativeScriptFromCsl(child)); - } - return { type: "all", scripts }; - } - - const saAny = ns.as_script_any(); - if (saAny) { - const list = saAny.native_scripts(); - const scripts: DecodedNativeScript[] = []; - for (let i = 0; i < list.len(); i++) { - const child = list.get(i); - scripts.push(decodeNativeScriptFromCsl(child)); - } - return { type: "any", scripts }; - } - - const sn = ns.as_script_n_of_k(); - if (sn) { - const list = sn.native_scripts(); - const scripts: DecodedNativeScript[] = []; - for (let i = 0; i < list.len(); i++) { - const child = list.get(i); - scripts.push(decodeNativeScriptFromCsl(child)); - } - const n = sn.n(); - const required = typeof n === "number" ? n : Number((n as unknown as { to_str?: () => string }).to_str?.() ?? n as unknown as number); - return { type: "atLeast", required, scripts }; - } - - // Unknown variant; default to requiring 1 signature - return { type: "atLeast", required: 1, scripts: [] }; -} - -function computeRequiredSigners(script: DecodedNativeScript): number { - switch (script.type) { - case "sig": - return 1; - case "timelockStart": - case "timelockExpiry": - return 0; - case "any": - if (script.scripts.length === 0) return 0; - return Math.min(...script.scripts.map((s) => computeRequiredSigners(s))); - case "all": { - let total = 0; - for (const s of script.scripts) total += computeRequiredSigners(s); - return total; - } - case "atLeast": - if (script.scripts.length === 0) return Math.max(0, script.required); - const childReqs = script.scripts.map((s) => computeRequiredSigners(s)).sort((a, b) => a - b); - const need = Math.max(0, Math.min(script.required, childReqs.length)); - let sum = 0; - for (let i = 0; i < need; i++) sum += childReqs[i]!; - return sum; - default: - return 0; - } -} - -// A script is considered hierarchical ONLY if some signature node is nested under -// two or more logical groups (all/any/atLeast). Examples: -// - atLeast(sig, sig) -> NOT hierarchical (sig depth = 1) -// - all(any(sig, ...), ...) -> hierarchical (sig depth = 2) -// Timelock nodes are ignored and do not contribute to depth. -function isHierarchicalScript(script: DecodedNativeScript): boolean { - return hasSigWithLogicalDepth(script, 0); -} - -function hasSigWithLogicalDepth(node: DecodedNativeScript, logicalDepth: number): boolean { - if (node.type === "sig") { - // Require depth >= 2: root logical group -> child logical group -> sig - return logicalDepth >= 2; - } - if (node.type === "all" || node.type === "any" || node.type === "atLeast") { - for (const child of node.scripts) { - if (hasSigWithLogicalDepth(child, logicalDepth + 1)) return true; - } - return false; - } - // Timelock nodes do not count towards logical depth and have no children to traverse - return false; -} - -// Collect all sig key hashes from a decoded native script tree -function collectSigKeyHashes(node: DecodedNativeScript): string[] { - if (node.type === "sig") return [node.keyHash.toLowerCase()]; - if (node.type === "all" || node.type === "any" || node.type === "atLeast") { - const out: string[] = []; - for (const child of node.scripts) out.push(...collectSigKeyHashes(child)); - // De-duplicate in case of repeated leaves - return Array.from(new Set(out)); - } - return []; -} +// Native script decoding helpers are now imported from @/utils/nativeScriptUtils // Match payment script sig key hashes to signer payment addresses function matchPaymentSigs(sigKeyHashes: string[], signerAddresses: string[]): SigMatch[] {