@sundaeswap/sprinkles 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ import {
6
6
  HotWallet,
7
7
  type Wallet,
8
8
  } from "@blaze-cardano/sdk";
9
- import { CborSet, VkeyWitness } from "@blaze-cardano/core";
9
+ import { CborSet, VkeyWitness, blake2b_256, TxCBOR } from "@blaze-cardano/core";
10
10
  import { confirm, input, password, search, select } from "@inquirer/prompts";
11
11
  import {
12
12
  Kind,
@@ -72,6 +72,16 @@ export interface IProfileEntry {
72
72
  meta: IProfileMeta;
73
73
  }
74
74
 
75
+ export interface TxDialogResult {
76
+ action: "submitted" | "signed" | "cancelled";
77
+ txId?: string; // present only if action === 'submitted'
78
+ tx: Core.Transaction; // the (potentially signed) transaction
79
+ }
80
+
81
+ export interface TxDialogOptions {
82
+ beforeSign?: () => Promise<void>;
83
+ }
84
+
75
85
  export const NetworkSchema = Type.Union([
76
86
  Type.Literal("mainnet"),
77
87
  Type.Literal("preview"),
@@ -803,152 +813,380 @@ export class Sprinkle<S extends TSchema> {
803
813
  this.saveProfile();
804
814
  }
805
815
 
816
+ // --- TxDialog Helpers ---
817
+
818
+ /**
819
+ * Get the payment key hash from a HotWallet's first address
820
+ */
821
+ private async getWalletPaymentKeyHash(
822
+ wallet: HotWallet,
823
+ ): Promise<string | null> {
824
+ try {
825
+ const addresses = await wallet.getUsedAddresses();
826
+ const address = addresses[0];
827
+ if (!address) return null;
828
+ const paymentCredential = address.asBase()?.getPaymentCredential();
829
+ return paymentCredential?.hash?.toString() ?? null;
830
+ } catch {
831
+ return null;
832
+ }
833
+ }
834
+
835
+ /**
836
+ * Count the number of vkey signatures in a transaction's witness set
837
+ */
838
+ private countSignatures(tx: Core.Transaction): number {
839
+ const vkeys = tx.witnessSet().vkeys();
840
+ return vkeys ? vkeys.size() : 0;
841
+ }
842
+
843
+ /**
844
+ * Check if a specific public key has already signed the transaction
845
+ * Compares by vkey (public key bytes)
846
+ */
847
+ private hasVkeySigned(tx: Core.Transaction, vkeyHex: string): boolean {
848
+ const vkeys = tx.witnessSet().vkeys();
849
+ if (!vkeys) return false;
850
+ const vkeyArray = vkeys.toCore();
851
+ return vkeyArray.some(([vkey]) => vkey === vkeyHex);
852
+ }
853
+
854
+ /**
855
+ * Get the list of required signer key hashes from the transaction body
856
+ */
857
+ private getRequiredSigners(tx: Core.Transaction): string[] {
858
+ const requiredSigners = tx.body().requiredSigners();
859
+ if (!requiredSigners) return [];
860
+ return Array.from(requiredSigners.values()).map((s) => s.toString());
861
+ }
862
+
863
+ /**
864
+ * Compute the transaction body hash for display
865
+ */
866
+ private getTxBodyHash(tx: Core.Transaction): string {
867
+ const bodyCbor = tx.body().toCbor();
868
+ return blake2b_256(bodyCbor);
869
+ }
870
+
871
+ /**
872
+ * Format a hash for display: first 8 chars + ... + last 8 chars
873
+ */
874
+ private formatHash(hash: string): string {
875
+ if (hash.length <= 20) return hash;
876
+ return `${hash.slice(0, 8)}...${hash.slice(-8)}`;
877
+ }
878
+
879
+ /**
880
+ * Merge signatures from source transaction into target transaction.
881
+ * Prevents duplicate signatures by comparing vkey (public key).
882
+ * Returns the count of newly added signatures.
883
+ */
884
+ private mergeSignatures(
885
+ target: Core.Transaction,
886
+ source: Core.Transaction,
887
+ ): number {
888
+ const targetWs = target.witnessSet();
889
+ const sourceWs = source.witnessSet();
890
+
891
+ const targetVkeys = targetWs.vkeys()?.toCore() ?? [];
892
+ const sourceVkeys = sourceWs.vkeys()?.toCore() ?? [];
893
+
894
+ // Find vkeys in source that aren't in target (by comparing public key)
895
+ const existingPubKeys = new Set(targetVkeys.map(([vkey]) => vkey));
896
+ const newVkeys = sourceVkeys.filter(
897
+ ([vkey]) => !existingPubKeys.has(vkey),
898
+ );
899
+
900
+ if (newVkeys.length === 0) {
901
+ return 0;
902
+ }
903
+
904
+ // Merge the new vkeys into target
905
+ targetWs.setVkeys(
906
+ CborSet.fromCore([...targetVkeys, ...newVkeys], VkeyWitness.fromCore),
907
+ );
908
+ target.setWitnessSet(targetWs);
909
+
910
+ return newVkeys.length;
911
+ }
912
+
806
913
  async TxDialog<P extends Provider, W extends Wallet>(
807
914
  blaze: Blaze<P, W>,
808
915
  tx: Core.Transaction,
809
- opts?: { beforeSign?: () => Promise<void> },
810
- ): Promise<void> {
811
- const txCbor = tx.toCbor();
916
+ opts?: TxDialogOptions,
917
+ ): Promise<TxDialogResult> {
918
+ let currentTx = tx;
812
919
  let expanded = false;
920
+ let hasSignedThisSession = false;
921
+
922
+ // Check if wallet can sign (is HotWallet)
923
+ const isHotWallet = blaze.wallet instanceof HotWallet;
813
924
 
814
- const showDialog = async (): Promise<void> => {
925
+ // Get wallet's vkeys for detecting if already signed
926
+ let walletVkeys: string[] = [];
927
+ if (isHotWallet) {
928
+ try {
929
+ // Sign a dummy to get the wallet's public keys
930
+ // We'll use this to check if wallet has already signed
931
+ const wallet = blaze.wallet as unknown as HotWallet;
932
+ const addresses = await wallet.getUsedAddresses();
933
+ const address = addresses[0];
934
+ if (address) {
935
+ // We can't easily get vkeys without signing, so we'll track after first sign
936
+ }
937
+ } catch {
938
+ // Ignore errors in setup
939
+ }
940
+ }
941
+
942
+ while (true) {
943
+ // Display transaction status
944
+ const txHash = this.getTxBodyHash(currentTx);
945
+ const sigCount = this.countSignatures(currentTx);
946
+ const requiredSigners = this.getRequiredSigners(currentTx);
947
+
948
+ console.log("");
949
+ console.log(`Transaction: ${this.formatHash(txHash)}`);
950
+ if (requiredSigners.length > 0) {
951
+ console.log(`Signatures: ${sigCount} of ${requiredSigners.length} required`);
952
+ console.log("Required signers:");
953
+ for (const signer of requiredSigners) {
954
+ console.log(` - ${this.formatHash(signer)}`);
955
+ }
956
+ } else {
957
+ console.log(`Signatures: ${sigCount}`);
958
+ }
959
+
960
+ // Display CBOR
961
+ const txCbor = currentTx.toCbor();
815
962
  if (expanded) {
816
963
  console.log("Transaction CBOR:", txCbor);
817
964
  } else {
818
965
  console.log("Transaction CBOR:", `${String(txCbor).slice(0, 50)}...`);
819
966
  }
820
967
 
821
- const menuItems: TMenuItem<S>[] = [];
968
+ // Build dynamic menu choices
969
+ const choices: { name: string; value: string }[] = [];
822
970
 
971
+ // "Sign with this wallet" - only if HotWallet and hasn't signed this session
972
+ if (isHotWallet && !hasSignedThisSession) {
973
+ choices.push({ name: "Sign with this wallet", value: "sign" });
974
+ }
975
+
976
+ // CBOR options
823
977
  if (!expanded) {
824
- menuItems.push({
825
- title: "Expand CBOR",
826
- action: async () => {
827
- expanded = true;
828
- await showDialog();
829
- },
830
- });
978
+ choices.push({ name: "Expand CBOR", value: "expand" });
831
979
  }
980
+ choices.push({ name: "Copy CBOR to clipboard", value: "copy" });
981
+ choices.push({ name: "Import signatures from CBOR", value: "import" });
832
982
 
833
- menuItems.push({
834
- title: "Copy CBOR to clipboard",
835
- action: async () => {
836
- try {
837
- const { default: clipboard } = await import("clipboardy");
838
- clipboard.writeSync(String(txCbor));
839
- console.log("Transaction CBOR copied to clipboard.");
840
- } catch (e) {
841
- console.log("Failed to copy to clipboard, expanding instead.");
842
- expanded = true;
843
- await showDialog();
844
- }
845
- },
983
+ // Submit and cancel
984
+ choices.push({ name: "Submit transaction", value: "submit" });
985
+ choices.push({ name: "Cancel", value: "cancel" });
986
+
987
+ const selection = await select({
988
+ message: "Select an option:",
989
+ choices,
846
990
  });
847
991
 
848
- if (blaze.wallet instanceof HotWallet) {
849
- menuItems.push({
850
- title: "Sign and submit transaction",
851
- action: async () => {
852
- if (opts?.beforeSign) {
853
- await opts.beforeSign();
854
- }
992
+ // Handle selection
993
+ if (selection === "sign") {
994
+ if (opts?.beforeSign) {
995
+ await opts.beforeSign();
996
+ }
855
997
 
856
- // Detect if stake key signature is required
857
- let needsStakeKey = false;
858
- try {
859
- const wallet = blaze.wallet as unknown as HotWallet;
860
- const addresses = await wallet.getUsedAddresses();
861
- const userAddress = addresses[0];
862
- if (userAddress) {
863
- const stakeCredential = userAddress
864
- .asBase()
865
- ?.getStakeCredential();
866
- const stakeKeyHash = stakeCredential?.hash?.toString();
867
-
868
- if (stakeKeyHash) {
869
- const requiredSigners = tx.body().requiredSigners();
870
- if (requiredSigners) {
871
- const signerArray = Array.from(requiredSigners.values());
872
- needsStakeKey = signerArray.some(
873
- (signer) => signer.toString() === stakeKeyHash,
874
- );
875
- }
876
-
877
- const certs = tx.body().certs();
878
- const hasCertificates = certs && certs.size() > 0;
879
- const withdrawals = tx.body().withdrawals();
880
- const hasWithdrawals = withdrawals && withdrawals.size > 0;
881
-
882
- if (hasCertificates || hasWithdrawals) {
883
- needsStakeKey = true;
884
- }
885
- }
998
+ // Detect if stake key signature is required
999
+ let needsStakeKey = false;
1000
+ try {
1001
+ const wallet = blaze.wallet as unknown as HotWallet;
1002
+ const addresses = await wallet.getUsedAddresses();
1003
+ const userAddress = addresses[0];
1004
+ if (userAddress) {
1005
+ const stakeCredential = userAddress.asBase()?.getStakeCredential();
1006
+ const stakeKeyHash = stakeCredential?.hash?.toString();
1007
+
1008
+ if (stakeKeyHash) {
1009
+ const reqSigners = currentTx.body().requiredSigners();
1010
+ if (reqSigners) {
1011
+ const signerArray = Array.from(reqSigners.values());
1012
+ needsStakeKey = signerArray.some(
1013
+ (signer) => signer.toString() === stakeKeyHash,
1014
+ );
886
1015
  }
887
1016
 
888
- if (needsStakeKey) {
889
- console.log("Transaction requires stake key signature.");
890
- } else {
891
- console.log("Transaction requires payment key signature only.");
1017
+ const certs = currentTx.body().certs();
1018
+ const hasCertificates = certs && certs.size() > 0;
1019
+ const withdrawals = currentTx.body().withdrawals();
1020
+ const hasWithdrawals = withdrawals && withdrawals.size > 0;
1021
+
1022
+ if (hasCertificates || hasWithdrawals) {
1023
+ needsStakeKey = true;
892
1024
  }
893
- } catch (error) {
894
- console.warn(
895
- "Could not determine stake key requirement, signing with payment key only.",
896
- );
897
- console.warn(`Error: ${(error as Error).message}`);
898
1025
  }
1026
+ }
899
1027
 
900
- let signedTx;
901
-
902
- if (needsStakeKey) {
903
- const signed = await (
904
- blaze.wallet as unknown as HotWallet
905
- ).signTransaction(tx, true, true);
906
- const ws = tx.witnessSet();
907
- const vkeys = ws.vkeys()?.toCore() ?? [];
1028
+ if (needsStakeKey) {
1029
+ console.log("Transaction requires stake key signature.");
1030
+ } else {
1031
+ console.log("Transaction requires payment key signature only.");
1032
+ }
1033
+ } catch (error) {
1034
+ console.warn(
1035
+ "Could not determine stake key requirement, signing with payment key only.",
1036
+ );
1037
+ console.warn(`Error: ${(error as Error).message}`);
1038
+ }
908
1039
 
909
- const signedKeys = signed.vkeys();
910
- if (!signedKeys) {
911
- throw new Error(
912
- "signTransaction: no signed keys in wallet witness response",
913
- );
914
- }
1040
+ try {
1041
+ if (needsStakeKey) {
1042
+ const wallet = blaze.wallet as unknown as HotWallet;
1043
+ const signed = await wallet.signTransaction(currentTx, true, true);
1044
+ const ws = currentTx.witnessSet();
1045
+ const existingVkeys = ws.vkeys()?.toCore() ?? [];
1046
+
1047
+ const signedKeys = signed.vkeys();
1048
+ if (!signedKeys) {
1049
+ throw new Error(
1050
+ "signTransaction: no signed keys in wallet witness response",
1051
+ );
1052
+ }
915
1053
 
916
- if (
917
- signedKeys
918
- .toCore()
919
- .some(([vkey]) => vkeys.some(([key2]) => vkey === key2))
920
- ) {
921
- throw new Error(
922
- "signTransaction: some keys were already signed",
923
- );
924
- }
1054
+ // Check for duplicates before adding
1055
+ const newSignedKeys = signedKeys.toCore();
1056
+ const existingPubKeys = new Set(existingVkeys.map(([vkey]) => vkey));
1057
+ const uniqueNewKeys = newSignedKeys.filter(
1058
+ ([vkey]) => !existingPubKeys.has(vkey),
1059
+ );
925
1060
 
1061
+ if (uniqueNewKeys.length === 0) {
1062
+ console.log("Wallet has already signed this transaction.");
1063
+ } else {
926
1064
  ws.setVkeys(
927
1065
  CborSet.fromCore(
928
- [...signedKeys.toCore(), ...vkeys],
1066
+ [...existingVkeys, ...uniqueNewKeys],
929
1067
  VkeyWitness.fromCore,
930
1068
  ),
931
1069
  );
932
- tx.setWitnessSet(ws);
933
- signedTx = tx;
1070
+ currentTx.setWitnessSet(ws);
1071
+ console.log(`Added ${uniqueNewKeys.length} signature(s).`);
1072
+ hasSignedThisSession = true;
1073
+ }
1074
+ } else {
1075
+ const signedTx = await blaze.signTransaction(currentTx);
1076
+ // Merge signatures from signed tx into current tx
1077
+ const added = this.mergeSignatures(currentTx, signedTx);
1078
+ if (added > 0) {
1079
+ console.log(`Added ${added} signature(s).`);
1080
+ hasSignedThisSession = true;
934
1081
  } else {
935
- signedTx = await blaze.signTransaction(tx);
1082
+ console.log("Wallet has already signed this transaction.");
936
1083
  }
1084
+ }
1085
+ } catch (error) {
1086
+ console.error(`Signing failed: ${(error as Error).message}`);
1087
+ }
1088
+ // Continue loop after signing
1089
+ continue;
1090
+ }
937
1091
 
938
- const txId = await blaze.submitTransaction(signedTx);
939
- console.log(`Transaction submitted: ${txId}`);
940
- },
1092
+ if (selection === "expand") {
1093
+ expanded = true;
1094
+ continue;
1095
+ }
1096
+
1097
+ if (selection === "copy") {
1098
+ try {
1099
+ const { default: clipboard } = await import("clipboardy");
1100
+ clipboard.writeSync(String(currentTx.toCbor()));
1101
+ console.log("Transaction CBOR copied to clipboard.");
1102
+ } catch {
1103
+ console.log("Failed to copy to clipboard, expanding instead.");
1104
+ expanded = true;
1105
+ }
1106
+ continue;
1107
+ }
1108
+
1109
+ if (selection === "import") {
1110
+ const cborInput = await input({
1111
+ message: "Paste transaction CBOR (hex):",
941
1112
  });
1113
+
1114
+ if (!cborInput || cborInput.trim() === "") {
1115
+ console.log("No CBOR provided.");
1116
+ continue;
1117
+ }
1118
+
1119
+ try {
1120
+ const importedTx = Core.Transaction.fromCbor(
1121
+ TxCBOR(cborInput.trim()),
1122
+ );
1123
+
1124
+ // Validate body hash matches
1125
+ const currentHash = this.getTxBodyHash(currentTx);
1126
+ const importedHash = this.getTxBodyHash(importedTx);
1127
+
1128
+ if (currentHash !== importedHash) {
1129
+ const proceed = await confirm({
1130
+ message: `Warning: Imported transaction has different body hash.\nCurrent: ${this.formatHash(currentHash)}\nImported: ${this.formatHash(importedHash)}\nProceed anyway?`,
1131
+ default: false,
1132
+ });
1133
+ if (!proceed) {
1134
+ console.log("Import cancelled.");
1135
+ continue;
1136
+ }
1137
+ }
1138
+
1139
+ // Merge signatures
1140
+ const added = this.mergeSignatures(currentTx, importedTx);
1141
+ const sourceVkeys = importedTx.witnessSet().vkeys();
1142
+ const sourceCount = sourceVkeys ? sourceVkeys.size() : 0;
1143
+ const skipped = sourceCount - added;
1144
+
1145
+ if (added > 0) {
1146
+ console.log(`Added ${added} new signature(s).`);
1147
+ }
1148
+ if (skipped > 0) {
1149
+ console.log(`Skipped ${skipped} duplicate signature(s).`);
1150
+ }
1151
+ if (added === 0 && skipped === 0) {
1152
+ console.log("No signatures found in imported transaction.");
1153
+ }
1154
+ } catch (error) {
1155
+ console.error(`Failed to import CBOR: ${(error as Error).message}`);
1156
+ }
1157
+ continue;
942
1158
  }
943
1159
 
944
- const txMenu: IMenu<S> = {
945
- title: "Transaction Menu",
946
- items: menuItems,
947
- };
948
- await this._showMenu(txMenu, false);
949
- };
1160
+ if (selection === "submit") {
1161
+ const sigCount = this.countSignatures(currentTx);
1162
+ if (sigCount === 0) {
1163
+ const proceed = await confirm({
1164
+ message: "Warning: Transaction has no signatures. Submit anyway?",
1165
+ default: false,
1166
+ });
1167
+ if (!proceed) {
1168
+ continue;
1169
+ }
1170
+ }
950
1171
 
951
- await showDialog();
1172
+ try {
1173
+ const txId = await blaze.submitTransaction(currentTx);
1174
+ console.log(`Transaction submitted: ${txId}`);
1175
+ return { action: "submitted", txId, tx: currentTx };
1176
+ } catch (error) {
1177
+ console.error(`Submit failed: ${(error as Error).message}`);
1178
+ // Continue loop to allow retry or other actions
1179
+ continue;
1180
+ }
1181
+ }
1182
+
1183
+ if (selection === "cancel") {
1184
+ if (hasSignedThisSession) {
1185
+ return { action: "signed", tx: currentTx };
1186
+ }
1187
+ return { action: "cancelled", tx: currentTx };
1188
+ }
1189
+ }
952
1190
  }
953
1191
 
954
1192
  async EditStruct<U extends TSchema>(