diff --git a/coverage/lcov-report/base.css b/coverage/lcov-report/base.css new file mode 100644 index 0000000..f418035 --- /dev/null +++ b/coverage/lcov-report/base.css @@ -0,0 +1,224 @@ +body, html { + margin:0; padding: 0; + height: 100%; +} +body { + font-family: Helvetica Neue, Helvetica, Arial; + font-size: 14px; + color:#333; +} +.small { font-size: 12px; } +*, *:after, *:before { + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box; + } +h1 { font-size: 20px; margin: 0;} +h2 { font-size: 14px; } +pre { + font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; + margin: 0; + padding: 0; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} +a { color:#0074D9; text-decoration:none; } +a:hover { text-decoration:underline; } +.strong { font-weight: bold; } +.space-top1 { padding: 10px 0 0 0; } +.pad2y { padding: 20px 0; } +.pad1y { padding: 10px 0; } +.pad2x { padding: 0 20px; } +.pad2 { padding: 20px; } +.pad1 { padding: 10px; } +.space-left2 { padding-left:55px; } +.space-right2 { padding-right:20px; } +.center { text-align:center; } +.clearfix { display:block; } +.clearfix:after { + content:''; + display:block; + height:0; + clear:both; + visibility:hidden; + } +.fl { float: left; } +@media only screen and (max-width:640px) { + .col3 { width:100%; max-width:100%; } + .hide-mobile { display:none!important; } +} + +.quiet { + color: #7f7f7f; + color: rgba(0,0,0,0.5); +} +.quiet a { opacity: 0.7; } + +.fraction { + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 10px; + color: #555; + background: #E8E8E8; + padding: 4px 5px; + border-radius: 3px; + vertical-align: middle; +} + +div.path a:link, div.path a:visited { color: #333; } +table.coverage { + border-collapse: collapse; + margin: 10px 0 0 0; + padding: 0; +} + +table.coverage td { + margin: 0; + padding: 0; + vertical-align: top; +} +table.coverage td.line-count { + text-align: right; + padding: 0 5px 0 20px; +} +table.coverage td.line-coverage { + text-align: right; + padding-right: 10px; + min-width:20px; +} + +table.coverage td span.cline-any { + display: inline-block; + padding: 0 5px; + width: 100%; +} +.missing-if-branch { + display: inline-block; + margin-right: 5px; + border-radius: 3px; + position: relative; + padding: 0 4px; + background: #333; + color: yellow; +} + +.skip-if-branch { + display: none; + margin-right: 10px; + position: relative; + padding: 0 4px; + background: #ccc; + color: white; +} +.missing-if-branch .typ, .skip-if-branch .typ { + color: inherit !important; +} +.coverage-summary { + border-collapse: collapse; + width: 100%; +} +.coverage-summary tr { border-bottom: 1px solid #bbb; } +.keyline-all { border: 1px solid #ddd; } +.coverage-summary td, .coverage-summary th { padding: 10px; } +.coverage-summary tbody { border: 1px solid #bbb; } +.coverage-summary td { border-right: 1px solid #bbb; } +.coverage-summary td:last-child { border-right: none; } +.coverage-summary th { + text-align: left; + font-weight: normal; + white-space: nowrap; +} +.coverage-summary th.file { border-right: none !important; } +.coverage-summary th.pct { } +.coverage-summary th.pic, +.coverage-summary th.abs, +.coverage-summary td.pct, +.coverage-summary td.abs { text-align: right; } +.coverage-summary td.file { white-space: nowrap; } +.coverage-summary td.pic { min-width: 120px !important; } +.coverage-summary tfoot td { } + +.coverage-summary .sorter { + height: 10px; + width: 7px; + display: inline-block; + margin-left: 0.5em; + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; +} +.coverage-summary .sorted .sorter { + background-position: 0 -20px; +} +.coverage-summary .sorted-desc .sorter { + background-position: 0 -10px; +} +.status-line { height: 10px; } +/* yellow */ +.cbranch-no { background: yellow !important; color: #111; } +/* dark red */ +.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } +.low .chart { border:1px solid #C21F39 } +.highlighted, +.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ + background: #C21F39 !important; +} +/* medium red */ +.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } +/* light red */ +.low, .cline-no { background:#FCE1E5 } +/* light green */ +.high, .cline-yes { background:rgb(230,245,208) } +/* medium green */ +.cstat-yes { background:rgb(161,215,106) } +/* dark green */ +.status-line.high, .high .cover-fill { background:rgb(77,146,33) } +.high .chart { border:1px solid rgb(77,146,33) } +/* dark yellow (gold) */ +.status-line.medium, .medium .cover-fill { background: #f9cd0b; } +.medium .chart { border:1px solid #f9cd0b; } +/* light yellow */ +.medium { background: #fff4c2; } + +.cstat-skip { background: #ddd; color: #111; } +.fstat-skip { background: #ddd; color: #111 !important; } +.cbranch-skip { background: #ddd !important; color: #111; } + +span.cline-neutral { background: #eaeaea; } + +.coverage-summary td.empty { + opacity: .5; + padding-top: 4px; + padding-bottom: 4px; + line-height: 1; + color: #888; +} + +.cover-fill, .cover-empty { + display:inline-block; + height: 12px; +} +.chart { + line-height: 0; +} +.cover-empty { + background: white; +} +.cover-full { + border-right: none !important; +} +pre.prettyprint { + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +.com { color: #999 !important; } +.ignore-none { color: #999; font-weight: normal; } + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -48px; +} +.footer, .push { + height: 48px; +} diff --git a/coverage/lcov-report/block-navigation.js b/coverage/lcov-report/block-navigation.js new file mode 100644 index 0000000..530d1ed --- /dev/null +++ b/coverage/lcov-report/block-navigation.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +var jumpToCode = (function init() { + // Classes of code we would like to highlight in the file view + var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; + + // Elements to highlight in the file listing view + var fileListingElements = ['td.pct.low']; + + // We don't want to select elements that are direct descendants of another match + var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` + + // Selector that finds elements on the page to which we can jump + var selector = + fileListingElements.join(', ') + + ', ' + + notSelector + + missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` + + // The NodeList of matching elements + var missingCoverageElements = document.querySelectorAll(selector); + + var currentIndex; + + function toggleClass(index) { + missingCoverageElements + .item(currentIndex) + .classList.remove('highlighted'); + missingCoverageElements.item(index).classList.add('highlighted'); + } + + function makeCurrent(index) { + toggleClass(index); + currentIndex = index; + missingCoverageElements.item(index).scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }); + } + + function goToPrevious() { + var nextIndex = 0; + if (typeof currentIndex !== 'number' || currentIndex === 0) { + nextIndex = missingCoverageElements.length - 1; + } else if (missingCoverageElements.length > 1) { + nextIndex = currentIndex - 1; + } + + makeCurrent(nextIndex); + } + + function goToNext() { + var nextIndex = 0; + + if ( + typeof currentIndex === 'number' && + currentIndex < missingCoverageElements.length - 1 + ) { + nextIndex = currentIndex + 1; + } + + makeCurrent(nextIndex); + } + + return function jump(event) { + if ( + document.getElementById('fileSearch') === document.activeElement && + document.activeElement != null + ) { + // if we're currently focused on the search input, we don't want to navigate + return; + } + + switch (event.which) { + case 78: // n + case 74: // j + goToNext(); + break; + case 66: // b + case 75: // k + case 80: // p + goToPrevious(); + break; + } + }; +})(); +window.addEventListener('keydown', jumpToCode); diff --git a/coverage/lcov-report/favicon.png b/coverage/lcov-report/favicon.png new file mode 100644 index 0000000..c1525b8 Binary files /dev/null and b/coverage/lcov-report/favicon.png differ diff --git a/coverage/lcov-report/index.html b/coverage/lcov-report/index.html new file mode 100644 index 0000000..fc44134 --- /dev/null +++ b/coverage/lcov-report/index.html @@ -0,0 +1,191 @@ + + + + + + Code coverage report for All files + + + + + + + + + +
+
+

All files

+
+ +
+ 93.4% + Statements + 1557/1667 +
+ + +
+ 86.85% + Branches + 1097/1263 +
+ + +
+ 95.88% + Functions + 256/267 +
+ + +
+ 95.22% + Lines + 1476/1550 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
src +
+
96.26%1081/112389.98%728/80998.28%172/17598.1%1033/1053
src/adapters +
+
92.4%73/7974.41%64/8693.75%15/1692.2%71/77
src/dashboard +
+
94.61%158/16791.94%194/211100%20/2096.37%133/138
src/nonce +
+
93.1%81/8782.97%39/4795%19/2094.04%79/84
src/relayer +
+
77.72%164/21165.45%72/11083.33%30/3680.8%160/198
src/types +
+
0%0/00%0/00%0/00%0/0
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/prettify.css b/coverage/lcov-report/prettify.css new file mode 100644 index 0000000..b317a7c --- /dev/null +++ b/coverage/lcov-report/prettify.css @@ -0,0 +1 @@ +.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/coverage/lcov-report/prettify.js b/coverage/lcov-report/prettify.js new file mode 100644 index 0000000..b322523 --- /dev/null +++ b/coverage/lcov-report/prettify.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/coverage/lcov-report/sort-arrow-sprite.png b/coverage/lcov-report/sort-arrow-sprite.png new file mode 100644 index 0000000..6ed6831 Binary files /dev/null and b/coverage/lcov-report/sort-arrow-sprite.png differ diff --git a/coverage/lcov-report/sorter.js b/coverage/lcov-report/sorter.js new file mode 100644 index 0000000..4ed70ae --- /dev/null +++ b/coverage/lcov-report/sorter.js @@ -0,0 +1,210 @@ +/* eslint-disable */ +var addSorting = (function() { + 'use strict'; + var cols, + currentSort = { + index: 0, + desc: false + }; + + // returns the summary table element + function getTable() { + return document.querySelector('.coverage-summary'); + } + // returns the thead element of the summary table + function getTableHeader() { + return getTable().querySelector('thead tr'); + } + // returns the tbody element of the summary table + function getTableBody() { + return getTable().querySelector('tbody'); + } + // returns the th element for nth column + function getNthColumn(n) { + return getTableHeader().querySelectorAll('th')[n]; + } + + function onFilterInput() { + const searchValue = document.getElementById('fileSearch').value; + const rows = document.getElementsByTagName('tbody')[0].children; + + // Try to create a RegExp from the searchValue. If it fails (invalid regex), + // it will be treated as a plain text search + let searchRegex; + try { + searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive + } catch (error) { + searchRegex = null; + } + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + let isMatch = false; + + if (searchRegex) { + // If a valid regex was created, use it for matching + isMatch = searchRegex.test(row.textContent); + } else { + // Otherwise, fall back to the original plain text search + isMatch = row.textContent + .toLowerCase() + .includes(searchValue.toLowerCase()); + } + + row.style.display = isMatch ? '' : 'none'; + } + } + + // loads the search box + function addSearchBox() { + var template = document.getElementById('filterTemplate'); + var templateClone = template.content.cloneNode(true); + templateClone.getElementById('fileSearch').oninput = onFilterInput; + template.parentElement.appendChild(templateClone); + } + + // loads all columns + function loadColumns() { + var colNodes = getTableHeader().querySelectorAll('th'), + colNode, + cols = [], + col, + i; + + for (i = 0; i < colNodes.length; i += 1) { + colNode = colNodes[i]; + col = { + key: colNode.getAttribute('data-col'), + sortable: !colNode.getAttribute('data-nosort'), + type: colNode.getAttribute('data-type') || 'string' + }; + cols.push(col); + if (col.sortable) { + col.defaultDescSort = col.type === 'number'; + colNode.innerHTML = + colNode.innerHTML + ''; + } + } + return cols; + } + // attaches a data attribute to every tr element with an object + // of data values keyed by column name + function loadRowData(tableRow) { + var tableCols = tableRow.querySelectorAll('td'), + colNode, + col, + data = {}, + i, + val; + for (i = 0; i < tableCols.length; i += 1) { + colNode = tableCols[i]; + col = cols[i]; + val = colNode.getAttribute('data-value'); + if (col.type === 'number') { + val = Number(val); + } + data[col.key] = val; + } + return data; + } + // loads all row data + function loadData() { + var rows = getTableBody().querySelectorAll('tr'), + i; + + for (i = 0; i < rows.length; i += 1) { + rows[i].data = loadRowData(rows[i]); + } + } + // sorts the table using the data for the ith column + function sortByIndex(index, desc) { + var key = cols[index].key, + sorter = function(a, b) { + a = a.data[key]; + b = b.data[key]; + return a < b ? -1 : a > b ? 1 : 0; + }, + finalSorter = sorter, + tableBody = document.querySelector('.coverage-summary tbody'), + rowNodes = tableBody.querySelectorAll('tr'), + rows = [], + i; + + if (desc) { + finalSorter = function(a, b) { + return -1 * sorter(a, b); + }; + } + + for (i = 0; i < rowNodes.length; i += 1) { + rows.push(rowNodes[i]); + tableBody.removeChild(rowNodes[i]); + } + + rows.sort(finalSorter); + + for (i = 0; i < rows.length; i += 1) { + tableBody.appendChild(rows[i]); + } + } + // removes sort indicators for current column being sorted + function removeSortIndicators() { + var col = getNthColumn(currentSort.index), + cls = col.className; + + cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); + col.className = cls; + } + // adds sort indicators for current column being sorted + function addSortIndicators() { + getNthColumn(currentSort.index).className += currentSort.desc + ? ' sorted-desc' + : ' sorted'; + } + // adds event listeners for all sorter widgets + function enableUI() { + var i, + el, + ithSorter = function ithSorter(i) { + var col = cols[i]; + + return function() { + var desc = col.defaultDescSort; + + if (currentSort.index === i) { + desc = !currentSort.desc; + } + sortByIndex(i, desc); + removeSortIndicators(); + currentSort.index = i; + currentSort.desc = desc; + addSortIndicators(); + }; + }; + for (i = 0; i < cols.length; i += 1) { + if (cols[i].sortable) { + // add the click event handler on the th so users + // dont have to click on those tiny arrows + el = getNthColumn(i).querySelector('.sorter').parentElement; + if (el.addEventListener) { + el.addEventListener('click', ithSorter(i)); + } else { + el.attachEvent('onclick', ithSorter(i)); + } + } + } + } + // adds sorting functionality to the UI + return function() { + if (!getTable()) { + return; + } + cols = loadColumns(); + loadData(); + addSearchBox(); + addSortIndicators(); + enableUI(); + }; +})(); + +window.addEventListener('load', addSorting); diff --git a/coverage/lcov-report/src/adapters/index.html b/coverage/lcov-report/src/adapters/index.html new file mode 100644 index 0000000..bb406bc --- /dev/null +++ b/coverage/lcov-report/src/adapters/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for src/adapters + + + + + + + + + +
+
+

All files src/adapters

+
+ +
+ 92.4% + Statements + 73/79 +
+ + +
+ 74.41% + Branches + 64/86 +
+ + +
+ 93.75% + Functions + 15/16 +
+ + +
+ 92.2% + Lines + 71/77 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
index.ts +
+
0%0/00%0/00%0/00%0/0
keypair.ts +
+
100%11/11100%4/4100%4/4100%11/11
types.ts +
+
0%0/00%0/00%0/00%0/0
walletconnect.ts +
+
91.17%62/6873.17%60/8291.66%11/1290.9%60/66
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/adapters/index.ts.html b/coverage/lcov-report/src/adapters/index.ts.html new file mode 100644 index 0000000..4aeb202 --- /dev/null +++ b/coverage/lcov-report/src/adapters/index.ts.html @@ -0,0 +1,106 @@ + + + + + + Code coverage report for src/adapters/index.ts + + + + + + + + + +
+
+

All files / src/adapters index.ts

+
+ +
+ 0% + Statements + 0/0 +
+ + +
+ 0% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/0 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8  +  +  +  +  +  +  + 
export type { WalletAdapter, SignTransactionOptions } from './types.js';
+export { KeypairWalletAdapter } from './keypair.js';
+export {
+  WalletConnectAdapter,
+  type WalletConnectAdapterOptions,
+  type WalletConnectAppMetadata,
+} from './walletconnect.js';
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/adapters/keypair.ts.html b/coverage/lcov-report/src/adapters/keypair.ts.html new file mode 100644 index 0000000..d766bd4 --- /dev/null +++ b/coverage/lcov-report/src/adapters/keypair.ts.html @@ -0,0 +1,199 @@ + + + + + + Code coverage report for src/adapters/keypair.ts + + + + + + + + + +
+
+

All files / src/adapters keypair.ts

+
+ +
+ 100% + Statements + 11/11 +
+ + +
+ 100% + Branches + 4/4 +
+ + +
+ 100% + Functions + 4/4 +
+ + +
+ 100% + Lines + 11/11 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39  +  +  +  +  +  +  +  +50x +  +  +34x +  +  +  +  +  +  +17x +2x +1x +  +  +  +  +1x +1x +1x +  +  +15x +15x +  +  +  +1x +  +  + 
import { Keypair, Transaction } from '@stellar/stellar-sdk';
+import type { WalletAdapter, SignTransactionOptions } from './types.js';
+ 
+/**
+ * Adapter wrapping a standard @stellar/stellar-sdk Keypair.
+ * Allows secret keys / keypairs to be used seamlessly through the WalletAdapter interface.
+ */
+export class KeypairWalletAdapter implements WalletAdapter {
+  constructor(private readonly keypair: Keypair) {}
+ 
+  getPublicKey(): string {
+    return this.keypair.publicKey();
+  }
+ 
+  async signTransaction(
+    tx: Transaction | string,
+    _opts?: SignTransactionOptions,
+  ): Promise<Transaction | string> {
+    if (typeof tx === 'string') {
+      if (!_opts?.networkPassphrase) {
+        throw new Error(
+          'networkPassphrase is required when signing a raw XDR string. ' +
+            'Pass { networkPassphrase } in the options argument.',
+        );
+      }
+      const parsedTx = new Transaction(tx, _opts.networkPassphrase);
+      parsedTx.sign(this.keypair);
+      return parsedTx.toXDR();
+    }
+ 
+    tx.sign(this.keypair);
+    return tx;
+  }
+ 
+  isConnected(): boolean {
+    return true;
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/adapters/types.ts.html b/coverage/lcov-report/src/adapters/types.ts.html new file mode 100644 index 0000000..1cfb2af --- /dev/null +++ b/coverage/lcov-report/src/adapters/types.ts.html @@ -0,0 +1,226 @@ + + + + + + Code coverage report for src/adapters/types.ts + + + + + + + + + +
+
+

All files / src/adapters types.ts

+
+ +
+ 0% + Statements + 0/0 +
+ + +
+ 0% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/0 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import type { Transaction } from '@stellar/stellar-sdk';
+ 
+/**
+ * Common sign transaction options.
+ */
+export interface SignTransactionOptions {
+  networkPassphrase?: string;
+  accountToSign?: string;
+}
+ 
+/**
+ * Generic wallet adapter interface for Stellar / Soroban transaction signing.
+ * Allows StreamFi SDK to support browser wallets, mobile wallets (WalletConnect v2),
+ * Freighter, Albedo, or direct Keypairs uniformly.
+ */
+export interface WalletAdapter {
+  /**
+   * Returns the Stellar public key / G-address of the active account.
+   */
+  getPublicKey(): Promise<string> | string;
+ 
+  /**
+   * Signs a Soroban Transaction instance or XDR string.
+   * Returns the signed Transaction instance or signed XDR string.
+   */
+  signTransaction(
+    tx: Transaction | string,
+    opts?: SignTransactionOptions,
+  ): Promise<Transaction | string>;
+ 
+  /**
+   * Checks if the wallet is currently connected.
+   */
+  isConnected?(): boolean;
+ 
+  /**
+   * Initiates wallet connection / session handshake if applicable.
+   * Returns the connected account public key.
+   */
+  connect?(): Promise<string>;
+ 
+  /**
+   * Disconnects the wallet session if applicable.
+   */
+  disconnect?(): Promise<void>;
+  chainId?: string;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/adapters/walletconnect.ts.html b/coverage/lcov-report/src/adapters/walletconnect.ts.html new file mode 100644 index 0000000..9783eec --- /dev/null +++ b/coverage/lcov-report/src/adapters/walletconnect.ts.html @@ -0,0 +1,907 @@ + + + + + + Code coverage report for src/adapters/walletconnect.ts + + + + + + + + + +
+
+

All files / src/adapters walletconnect.ts

+
+ +
+ 91.17% + Statements + 62/68 +
+ + +
+ 73.17% + Branches + 60/82 +
+ + +
+ 91.66% + Functions + 11/12 +
+ + +
+ 90.9% + Lines + 60/66 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +7x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +51x +51x +  +  +  +  +51x +6x +6x +  +  +  +  +  +45x +45x +45x +51x +51x +  +  +  +  +  +  +  +  +  +  +  +  +  +26x +  +  +  +  +  +  +11x +11x +3x +  +8x +  +  +  +  +  +  +6x +1x +  +  +5x +5x +  +  +  +  +  +  +  +  +  +  +  +  +2x +1x +1x +1x +  +  +  +  +  +  +1x +1x +  +  +  +1x +  +  +  +  +  +  +  +  +  +10x +10x +5x +  +  +  +10x +  +  +  +  +  +  +5x +5x +5x +4x +  +  +  +  +  +  +  +  +5x +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +  +1x +  +  +  +1x +1x +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +1x +  +  +  +1x +  +  +  +1x +  +  +  +  +  +  +  +  +28x +  +  +26x +8x +  +  +  +  +  +18x +18x +8x +8x +7x +  +  +  +  +11x +5x +5x +  +  +6x +  +  + 
import { Transaction } from '@stellar/stellar-sdk';
+import type { WalletAdapter, SignTransactionOptions } from './types.js';
+ 
+export interface WalletConnectAppMetadata {
+  name: string;
+  description: string;
+  url: string;
+  icons: string[];
+}
+ 
+export interface WalletConnectSignClient {
+  connect?: (opts: unknown) => Promise<{ session?: WalletConnectSession; approval?: () => Promise<WalletConnectSession> }>;
+  disconnect?: (opts: unknown) => Promise<void>;
+  request?: (opts: unknown) => Promise<unknown>;
+  [key: string]: unknown;
+}
+ 
+export interface WalletConnectSession {
+  topic?: string;
+  account?: string;
+  namespaces?: Record<string, { accounts?: string[] }>;
+  accounts?: string[];
+  [key: string]: unknown;
+}
+ 
+export interface WalletConnectAdapterOptions {
+  /** WalletConnect v2 Project ID */
+  projectId?: string;
+  /**
+   * CAIP-2 chain identifier (e.g., 'stellar:pubnet', 'stellar:testnet').
+   * Must be one of: 'stellar:pubnet', 'stellar:testnet', 'stellar:local'.
+   * Defaults to 'stellar:pubnet'.
+   */
+  chainId?: string | undefined;
+  /** DApp metadata for WalletConnect modal/handshake */
+  metadata?: WalletConnectAppMetadata;
+  /** Optional pre-existing WalletConnect SignClient or provider instance */
+  client?: WalletConnectSignClient | unknown;
+  /** Optional active session object or mock session */
+  session?: WalletConnectSession | unknown;
+  /** Milliseconds to wait for the connect handshake before rejecting. Defaults to 30000. */
+  connectTimeoutMs?: number;
+}
+ 
+/**
+ * Exhaustive map of CAIP-2 identifiers this adapter accepts to their
+ * human-readable Stellar network names. Anything not in this map is
+ * rejected at construction time so an invalid chain ID never silently
+ * reaches the smart contract (fixes #157).
+ */
+const SUPPORTED_CAIP2_CHAINS: Readonly<Record<string, string>> = {
+  'stellar:pubnet':  'mainnet',
+  'stellar:testnet': 'testnet',
+  'stellar:local':   'local',
+};
+ 
+/**
+ * Native WalletConnect v2 adapter for mobile and browser-based wallet integration.
+ * Supports Stellar / Soroban transaction signing via CAIP-2 RPC protocols.
+ */
+export class WalletConnectAdapter implements WalletAdapter {
+  private readonly projectId?: string | undefined;
+  public readonly chainId: string;
+  private readonly metadata?: WalletConnectAppMetadata | undefined;
+  private client: WalletConnectSignClient | null;
+  private session: WalletConnectSession | null;
+  private readonly connectTimeoutMs: number;
+ 
+  constructor(options: WalletConnectAdapterOptions = {}) {
+    this.projectId = options.projectId;
+    const chainId  = options.chainId ?? 'stellar:pubnet';
+ 
+    // Validate the CAIP-2 chain identifier at construction time so callers
+    // get an immediate, descriptive error instead of a silent cross-chain
+    // payload submission later (fixes #157).
+    if (!(chainId in SUPPORTED_CAIP2_CHAINS)) {
+      const supported = Object.keys(SUPPORTED_CAIP2_CHAINS).join(', ');
+      throw new Error(
+        `WalletConnectAdapter: unsupported chainId '${chainId}'. ` +
+        `Supported chains: ${supported}.`,
+      );
+    }
+ 
+    this.chainId   = chainId;
+    this.metadata  = options.metadata;
+    this.client    = (options.client as WalletConnectSignClient) ?? null;
+    this.session   = (options.session as WalletConnectSession) ?? null;
+    this.connectTimeoutMs = options.connectTimeoutMs ?? 30000;
+  }
+ 
+  /**
+   * Set or update active session.
+   */
+  setSession(session: WalletConnectSession | unknown): void {
+    this.session = (session as WalletConnectSession) ?? null;
+  }
+ 
+  /**
+   * Returns true if an active WalletConnect session exists.
+   */
+  isConnected(): boolean {
+    return Boolean(this.session && this.getPublicKeyFromSession());
+  }
+ 
+  /**
+   * Gets active public key from session or throws error if not connected.
+   */
+  async getPublicKey(): Promise<string> {
+    const pubKey = this.getPublicKeyFromSession();
+    if (!pubKey) {
+      throw new Error('WalletConnect adapter is not connected. Call connect() first.');
+    }
+    return pubKey;
+  }
+ 
+  /**
+   * Connect to wallet via WalletConnect v2 handshake or pairing.
+   */
+  async connect(): Promise<string> {
+    if (this.isConnected()) {
+      return this.getPublicKey();
+    }
+ 
+    Eif (this.client && typeof this.client.connect === 'function') {
+      const connectResult = await this._withTimeout(
+        this.client.connect({
+          requiredNamespaces: {
+            stellar: {
+              methods: ['stellar_signTransaction', 'stellar_signXdr', 'soroban_signTransaction'],
+              chains: [this.chainId],
+              events: ['accountsChanged', 'chainChanged'],
+            },
+          },
+        }),
+        'WalletConnect connect() handshake'
+      );
+ 
+      if (connectResult.session) {
+        this.session = connectResult.session;
+      } else Eif (connectResult.approval) {
+        this.session = await this._withTimeout(
+          connectResult.approval(),
+          'WalletConnect approval()'
+        );
+      }
+    }
+ 
+    const pubKey = this.getPublicKeyFromSession();
+    Iif (!pubKey) {
+      throw new Error('Failed to establish WalletConnect session or obtain public key.');
+    }
+ 
+    return pubKey;
+  }
+ 
+  /**
+   * Races a network-bound promise against a timeout so that a dropped
+   * connection during the handshake rejects cleanly instead of hanging
+   * the init promise indefinitely (see #116).
+   */
+  private _withTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
+    let timer: ReturnType<typeof setTimeout>;
+    const timeout = new Promise<never>((_, reject) => {
+      timer = setTimeout(() => {
+        reject(new Error(`${label} timed out after ${this.connectTimeoutMs}ms (possible network interruption)`));
+      }, this.connectTimeoutMs);
+    });
+ 
+    return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
+  }
+ 
+  /**
+   * Disconnect the WalletConnect session.
+   */
+  async disconnect(): Promise<void> {
+    const session = this.session;
+    try {
+      if (this.client && session && typeof this.client.disconnect === 'function') {
+        await this._withTimeout(
+          this.client.disconnect({
+            topic: session.topic,
+            reason: { code: 6000, message: 'User disconnected' },
+          }),
+          'WalletConnect disconnect()'
+        );
+      }
+    } finally {
+      this.session = null;
+    }
+  }
+ 
+  /**
+   * Sign a transaction using WalletConnect v2 RPC call.
+   */
+  async signTransaction(
+    tx: Transaction | string,
+    opts?: SignTransactionOptions,
+  ): Promise<Transaction | string> {
+    const pubKey = await this.getPublicKey();
+    const xdrString = typeof tx === 'string' ? tx : tx.toXDR();
+    const passphrase = opts?.networkPassphrase;
+ 
+    Iif (!this.client || typeof this.client.request !== 'function') {
+      throw new Error('WalletConnect SignClient request handler is not available.');
+    }
+ 
+    const topic = this.session?.topic;
+    Iif (!topic) {
+      throw new Error('No active WalletConnect session topic found.');
+    }
+ 
+    // Send RPC request via WalletConnect v2
+    const result = await this.client.request({
+      topic,
+      chainId: this.chainId,
+      request: {
+        method: 'stellar_signTransaction',
+        params: {
+          xdr: xdrString,
+          accountToSign: opts?.accountToSign ?? pubKey,
+          networkPassphrase: passphrase,
+        },
+      },
+    });
+ 
+    const resRecord = result as Record<string, unknown> | string | undefined;
+    const signedXdr = typeof resRecord === 'string'
+      ? resRecord
+      : (resRecord?.['signedXdr'] ?? resRecord?.['xdr'] ?? resRecord);
+ 
+    Iif (typeof signedXdr !== 'string') {
+      throw new Error('WalletConnect response did not return valid signed XDR.');
+    }
+ 
+    Iif (typeof tx === 'string') {
+      return signedXdr;
+    }
+ 
+    return new Transaction(signedXdr, passphrase ?? '');
+  }
+ 
+  /**
+   * Extract account public key from CAIP-10 address format in WalletConnect session.
+   * e.g., 'stellar:pubnet:GABC123...' -> 'GABC123...'
+   * Uses safe fallback handling with nullish coalescing to prevent crashes from malformed formats.
+   */
+  private getPublicKeyFromSession(): string | null {
+    if (!this.session) return null;
+ 
+    // Direct account string on session
+    if (typeof this.session.account === 'string') {
+      return this.session.account.includes(':')
+        ? this.session.account.split(':').at(-1) ?? this.session.account
+        : this.session.account;
+    }
+ 
+    // CAIP-10 accounts in namespaces
+    const namespaces = this.session.namespaces;
+    if (namespaces && namespaces['stellar'] && Array.isArray(namespaces['stellar'].accounts)) {
+      const fullAccount = namespaces['stellar'].accounts[0];
+      if (fullAccount) {
+        return fullAccount.includes(':') ? fullAccount.split(':').at(-1) ?? fullAccount : fullAccount;
+      }
+    }
+ 
+    // Fallback: direct accounts array
+    if (Array.isArray(this.session.accounts) && this.session.accounts[0]) {
+      const fullAccount = this.session.accounts[0];
+      return fullAccount.includes(':') ? fullAccount.split(':').at(-1) ?? fullAccount : fullAccount;
+    }
+ 
+    return null;
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/builder.ts.html b/coverage/lcov-report/src/builder.ts.html new file mode 100644 index 0000000..7ab176a --- /dev/null +++ b/coverage/lcov-report/src/builder.ts.html @@ -0,0 +1,1753 @@ + + + + + + Code coverage report for src/builder.ts + + + + + + + + + +
+
+

All files / src builder.ts

+
+ +
+ 93.56% + Statements + 189/202 +
+ + +
+ 95.03% + Branches + 134/141 +
+ + +
+ 93.33% + Functions + 28/30 +
+ + +
+ 94.89% + Lines + 186/196 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557  +  +  +  +  +  +  +  +  +  +  +  +  +  +8x +8x +  +  +52x +52x +  +  +  +52x +  +  +  +16x +8x +8x +  +8x +  +  +  +16x +16x +8x +8x +  +  +  +  +  +  +  +  +  +  +  +  +52x +52x +52x +  +  +  +  +52x +52x +  +  +  +  +  +  +  +  +36x +36x +  +  +  +  +  +  +  +  +32x +32x +  +  +  +  +  +  +  +  +30x +30x +  +  +  +  +  +  +  +  +28x +6x +  +22x +22x +  +  +  +  +  +  +  +  +  +  +  +9x +4x +3x +  +  +5x +4x +  +  +2x +2x +  +  +  +  +  +  +  +  +  +  +29x +1x +  +28x +  +  +  +5x +  +  +23x +  +  +  +  +  +23x +2x +  +  +23x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +20x +1x +  +19x +1x +  +  +18x +18x +1x +  +  +  +17x +1x +  +  +  +  +  +16x +16x +16x +  +16x +20x +  +20x +16x +16x +  +  +16x +19x +  +  +  +19x +  +  +  +19x +19x +13x +13x +  +6x +1x +  +5x +5x +5x +  +3x +3x +3x +3x +3x +  +3x +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +16x +  +  +  +  +3x +  +  +  +4x +4x +  +  +4x +4x +  +  +  +98x +9x +  +  +89x +31x +2x +  +  +  +  +  +58x +3x +  +  +  +  +  +84x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +8x +  +  +  +  +  +  +  +  +120x +  +120x +7x +7x +  +  +113x +6x +6x +  +  +107x +562x +562x +8x +554x +4x +  +  +550x +  +  +550x +148x +148x +3x +  +  +  +  +550x +144x +144x +3x +  +  +  +  +550x +144x +144x +3x +  +  +  +  +550x +143x +143x +4x +  +  +  +  +  +107x +  +  +  +  +  +  +  +  +  +8x +8x +8x +8x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +63x +4x +  +  +59x +59x +30x +  +  +  +  +  +  +  +29x +29x +63x +  +63x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +67x +1x +  +  +66x +66x +66x +  +66x +66x +  +66x +  +66x +66x +  +  +66x +  +  +  +  +65x +65x +  +65x +65x +65x +  +65x +  +65x +65x +65x +5x +4x +  +1x +  +  +  +65x +65x +4x +4x +  +  +61x +61x +3x +  +  +  +  +  +3x +  +  +58x +  +  +  +  +58x +  +  +  +  +  +65x +1x +  +  +  +  +65x +  +  +  +  +  +  +  +100x +  +100x +100x +  +100x +1x +  +  +100x +  +  +100x +  +  +  +  +  +  +  +9x +9x +  +  +  +  +  +  +  +90x +90x +  +  + 
import { StrKey } from '@stellar/stellar-sdk';
+import { bigintSafeStringify } from './utils.js';
+ 
+export interface SubmitOptions {
+  maxRetries?: number;
+  retryDelayMs?: number;
+  /** Max concurrent in-flight submissions. Default 10. */
+  concurrency?: number;
+  /** Max pending queue size before backpressure kicks in. Default 100. */
+  maxQueueSize?: number;
+  /** AbortSignal to cancel the submission while in-flight. */
+  signal?: AbortSignal;
+}
+ 
+const DEFAULT_CONCURRENCY = 10;
+const DEFAULT_MAX_QUEUE_SIZE = 100;
+ 
+class Semaphore {
+  private _running = 0;
+  private _queue: Array<() => void> = [];
+  private readonly _max: number;
+ 
+  constructor(max: number) {
+    this._max = max;
+  }
+ 
+  async acquire(): Promise<void> {
+    if (this._running < this._max) {
+      this._running++;
+      return;
+    }
+    return new Promise<void>(resolve => { this._queue.push(resolve); });
+  }
+ 
+  release(): void {
+    this._running--;
+    if (this._queue.length > 0) {
+      this._running++;
+      this._queue.shift()!();
+    }
+  }
+}
+ 
+/** Fluent builder for constructing stream configurations. */
+export class StreamBuilder {
+  private _token?: string | undefined;
+  private _sender?: string | undefined;
+  private _recipient?: string | undefined;
+  private _amount?: number | undefined;
+  private _ratePerSecond?: number | bigint | undefined;
+ 
+  private pendingQueue: Set<Record<string, unknown>> = new Set();
+  private activeTimers: Set<NodeJS.Timeout> = new Set();
+  private isDestroyed = false;
+  private _semaphore: Semaphore;
+  private _maxQueueSize: number;
+ 
+  constructor(options?: { concurrency?: number; maxQueueSize?: number }) {
+    this._semaphore = new Semaphore(options?.concurrency ?? DEFAULT_CONCURRENCY);
+    this._maxQueueSize = options?.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE;
+  }
+ 
+  /**
+   * Sets the token contract address for the stream.
+   * @param address - The Soroban token contract address.
+   * @returns The builder instance for chaining.
+   */
+  token(address: string): this {
+    this._token = StreamBuilder._validateAddress(address, 'token');
+    return this;
+  }
+ 
+  /**
+   * Sets the sender address for the stream.
+   * @param address - The address that will send tokens.
+   * @returns The builder instance for chaining.
+   */
+  sender(address: string): this {
+    this._sender = StreamBuilder._validateAddress(address, 'sender');
+    return this;
+  }
+ 
+  /**
+   * Sets the recipient address for the stream.
+   * @param address - The address that will receive tokens.
+   * @returns The builder instance for chaining.
+   */
+  recipient(address: string): this {
+    this._recipient = StreamBuilder._validateAddress(address, 'recipient');
+    return this;
+  }
+ 
+  /**
+   * Sets the amount of tokens to stream.
+   * @param val - The amount in the token's smallest unit.
+   * @returns The builder instance for chaining.
+   */
+  amount(val: number): this {
+    if (!Number.isFinite(val) || val <= 0) {
+      throw new Error('Invalid StreamBuilder parameter: amount must be a positive finite number');
+    }
+    this._amount = val;
+    return this;
+  }
+ 
+  /**
+   * Sets the rate of tokens per second (in stroops).
+   * Accepts a number or bigint; bigint values are serialised to
+   * strings before network submission to avoid Safari/WebKit
+   * JSON.stringify quirks.
+   * @param val - The rate per second in stroops.
+   * @returns The builder instance for chaining.
+   */
+  ratePerSecond(val: number | bigint): this {
+    if (typeof val === 'bigint') {
+      if (val <= 0n) {
+        throw new Error('Invalid StreamBuilder parameter: ratePerSecond must be a positive value');
+      }
+    } else {
+      if (!Number.isFinite(val) || val <= 0) {
+        throw new Error('Invalid StreamBuilder parameter: ratePerSecond must be a positive finite number');
+      }
+    }
+    this._ratePerSecond = val;
+    return this;
+  }
+ 
+  /**
+   * Validates and produces the final stream configuration.
+   * Any bigint fields are converted to strings to guarantee safe
+   * serialisation across all browsers (Safari/WebKit included).
+   * @returns An object containing `token`, `sender`, `recipient`, `amount`, and optionally `ratePerSecond`.
+   * @throws {Error} If any required field (`token`, `sender`, `recipient`, `amount`) is missing or malformed.
+   */
+  build() {
+    if (this.isDestroyed) {
+      throw new Error('StreamBuilder has been destroyed');
+    }
+    if (this._token === undefined || this._token === null ||
+        this._sender === undefined || this._sender === null ||
+        this._recipient === undefined || this._recipient === null ||
+        this._amount === undefined || this._amount === null) {
+      throw new Error('Missing required parameters for StreamBuilder');
+    }
+ 
+    const config: Record<string, unknown> = {
+      token: this._token,
+      sender: this._sender,
+      recipient: this._recipient,
+      amount: this._amount,
+    };
+    if (this._ratePerSecond !== undefined && this._ratePerSecond !== null) {
+      config.ratePerSecond = this._ratePerSecond;
+    }
+ 
+    return bigintSafeStringify(config) as {
+      token: string;
+      sender: string;
+      recipient: string;
+      amount: number;
+      ratePerSecond?: string;
+    };
+  }
+ 
+  /**
+   * Submit built payload over network with automatic retries, payload queueing,
+   * and concurrency control to prevent degradation under high load.
+   */
+  async submit(
+    submitFn: (payload: Record<string, unknown>) => Promise<unknown>,
+    options: SubmitOptions = {}
+  ): Promise<unknown> {
+    if (this.isDestroyed) {
+      throw new Error('StreamBuilder has been destroyed');
+    }
+    if (typeof submitFn !== 'function') {
+      throw new Error('submitFn must be a valid function');
+    }
+ 
+    const { signal } = options;
+    if (signal?.aborted) {
+      throw new DOMException('Aborted', 'AbortError');
+    }
+ 
+    // Backpressure: reject if queue is full
+    if (this.pendingQueue.size >= this._maxQueueSize) {
+      throw new Error(
+        `StreamBuilder queue is full (${this._maxQueueSize} pending). ` +
+        'Retry later or increase maxQueueSize.'
+      );
+    }
+ 
+    const payload = this.build();
+    const payloadRecord = payload as unknown as Record<string, unknown>;
+    this.pendingQueue.add(payloadRecord);
+ 
+    const maxRetries = options.maxRetries ?? 3;
+    const baseRetryDelay = options.retryDelayMs ?? 100;
+ 
+    await this._semaphore.acquire();
+    try {
+      let attempt = 0;
+      let lastError: Error | unknown;
+ 
+      while (attempt <= maxRetries) {
+        Iif (this.isDestroyed) {
+          throw new Error('StreamBuilder was destroyed during submission');
+        }
+ 
+        Iif (signal?.aborted) {
+          throw new DOMException('Aborted', 'AbortError');
+        }
+ 
+        try {
+          const result = await submitFn(payloadRecord);
+          this.pendingQueue.delete(payloadRecord);
+          return result;
+        } catch (err) {
+          if (signal?.aborted) {
+            throw new DOMException('Aborted', 'AbortError');
+          }
+          lastError = err;
+          attempt++;
+          if (attempt <= maxRetries) {
+            // Exponential backoff: delay doubles each retry
+            const delay = baseRetryDelay * Math.pow(2, attempt - 1);
+            await new Promise<void>((resolve, reject) => {
+              const timer = setTimeout(() => {
+                this.activeTimers.delete(timer);
+                resolve();
+              }, delay);
+              this.activeTimers.add(timer);
+ 
+              Iif (signal) {
+                const onAbort = () => {
+                  clearTimeout(timer);
+                  this.activeTimers.delete(timer);
+                  signal.removeEventListener('abort', onAbort);
+                  reject(new DOMException('Aborted', 'AbortError'));
+                };
+                signal.addEventListener('abort', onAbort, { once: true });
+              }
+            });
+          }
+        }
+      }
+ 
+      throw new Error(
+        `StreamBuilder network payload submission failed after ${maxRetries} retries without payload drop: ${
+          lastError instanceof Error ? lastError.message : String(lastError)
+        }`
+      );
+    } finally {
+      this._semaphore.release();
+    }
+  }
+ 
+  getPendingQueue(): Array<Record<string, unknown>> {
+    return [...this.pendingQueue];
+  }
+ 
+  cleanup(): void {
+    this.isDestroyed = true;
+    for (const timer of this.activeTimers) {
+      clearTimeout(timer);
+    }
+    this.activeTimers.clear();
+    this.pendingQueue.clear();
+  }
+ 
+  private static _validateAddress(address: string, field: string): string {
+    if (typeof address !== 'string' || address.trim().length === 0) {
+      throw new Error(`Invalid StreamBuilder parameter: ${field} must be a non-empty string`);
+    }
+ 
+    if (field === 'token') {
+      if (!StrKey.isValidContract(address)) {
+        throw new Error(
+          `Invalid StreamBuilder parameter: ${field} must be a valid Soroban contract ID (C-address), got "${address}"`,
+        );
+      }
+    } else {
+      // sender / recipient — must be valid Ed25519 public keys (G-addresses)
+      if (!StrKey.isValidEd25519PublicKey(address)) {
+        throw new Error(
+          `Invalid StreamBuilder parameter: ${field} must be a valid Stellar public key (G-address), got "${address}"`,
+        );
+      }
+    }
+ 
+    return address;
+  }
+}
+ 
+export interface BatchOperation {
+  method: string;
+  params: Record<string, unknown>;
+}
+ 
+export interface BatchExecuteOptions {
+  maxBatchSize?: number;
+}
+ 
+export interface BatchResult {
+  success: boolean;
+  operations: number;
+  xdr: string;
+  chunks?: number;
+  errors?: string[];
+}
+ 
+const DEFAULT_MAX_BATCH_SIZE = 50;
+ 
+/**
+ * Validate that the input is a non-null, non-empty array of objects.
+ * Also validates individual fields (token, sender, recipient) for address format correctness.
+ * Returns an array of error messages, or an empty array if valid.
+ * Mandatory client-side validation prevents invalid payloads from reaching the smart contract.
+ */
+function validatePayload(streams: unknown): string[] {
+  const errors: string[] = [];
+ 
+  if (streams === null || streams === undefined) {
+    errors.push('Batch payload cannot be null or undefined');
+    return errors;
+  }
+ 
+  if (!Array.isArray(streams)) {
+    errors.push('Batch payload must be an array');
+    return errors;
+  }
+ 
+  for (let i = 0; i < streams.length; i++) {
+    const item = streams[i];
+    if (item === null || item === undefined) {
+      errors.push(`Batch item at index ${i} cannot be null or undefined`);
+    } else if (typeof item !== 'object') {
+      errors.push(`Batch item at index ${i} must be an object, got ${typeof item}`);
+    } else {
+      // Field-level validation for known address-type fields
+      const obj = item as Record<string, unknown>;
+ 
+      // Validate token field — must be a valid Soroban contract ID (C-address)
+      if (obj.token !== undefined && obj.token !== null) {
+        const token = String(obj.token);
+        if (!StrKey.isValidContract(token)) {
+          errors.push(`Batch item at index ${i}: token must be a valid Soroban contract ID (C-address), got "${token}"`);
+        }
+      }
+ 
+      // Validate sender field — must be a valid Ed25519 public key (G-address)
+      if (obj.sender !== undefined && obj.sender !== null) {
+        const sender = String(obj.sender);
+        if (!StrKey.isValidEd25519PublicKey(sender)) {
+          errors.push(`Batch item at index ${i}: sender must be a valid Stellar public key (G-address), got "${sender}"`);
+        }
+      }
+ 
+      // Validate recipient field — must be a valid Ed25519 public key (G-address)
+      if (obj.recipient !== undefined && obj.recipient !== null) {
+        const recipient = String(obj.recipient);
+        if (!StrKey.isValidEd25519PublicKey(recipient)) {
+          errors.push(`Batch item at index ${i}: recipient must be a valid Stellar public key (G-address), got "${recipient}"`);
+        }
+      }
+ 
+      // Validate amount field — must be a positive finite number (where present)
+      if (obj.amount !== undefined && obj.amount !== null) {
+        const amount = Number(obj.amount);
+        if (!Number.isFinite(amount) || amount <= 0) {
+          errors.push(`Batch item at index ${i}: amount must be a positive finite number, got "${obj.amount}"`);
+        }
+      }
+    }
+  }
+ 
+  return errors;
+}
+ 
+interface PendingBatch {
+  operations: BatchOperation[];
+  signal?: AbortSignal | undefined;
+  resolve: (result: BatchResult) => void;
+}
+ 
+export class ConduitBatcher {
+  private static activeCallbacks: Set<() => void> = new Set();
+  private static isDestroyed = false;
+  private static batchQueue: PendingBatch[] = [];
+  private static processingBatch = false;
+ 
+  /**
+   * Bundle multiple stream operations into a single transaction.
+   *
+   * Any `bigint` fields inside the stream objects are converted to
+   * strings before further processing so that downstream
+   * `JSON.stringify` calls produce valid payloads on Safari / WebKit
+   * browsers (which serialise bigint as `{}` instead of throwing).
+   *
+   * Invalid payloads return `{ success: false, errors: [...] }` rather
+   * than throwing. Only a destroyed batcher causes a throw.
+   */
+  static execute(
+    streams: Record<string, unknown>[],
+    options?: BatchExecuteOptions,
+  ): BatchResult {
+    if (ConduitBatcher.isDestroyed) {
+      throw new Error('ConduitBatcher has been destroyed');
+    }
+ 
+    const validationErrors = validatePayload(streams);
+    if (validationErrors.length > 0) {
+      return {
+        success: false,
+        operations: 0,
+        xdr: '',
+        errors: validationErrors,
+      };
+    }
+ 
+    const sanitized = streams.map(bigintSafeStringify);
+    const resolvedMaxBatchSize = options?.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
+    const chunks = sanitized.length === 0 ? 0 : Math.ceil(sanitized.length / resolvedMaxBatchSize);
+ 
+    return {
+      success: true,
+      operations: sanitized.length,
+      xdr: 'AAAA...mock...batch...XDR',
+      chunks,
+    };
+  }
+ 
+  /**
+   * Asynchronously execute a batch with lifecycle tracking.
+   * Ensures pending callbacks are tracked and can be cleaned up on teardown.
+   */
+  static async executeAsync(
+    operations: BatchOperation[],
+    signal?: AbortSignal,
+  ): Promise<BatchResult> {
+    if (ConduitBatcher.isDestroyed) {
+      throw new Error('ConduitBatcher has been destroyed');
+    }
+ 
+    return new Promise<BatchResult>((resolve) => {
+      const entry: PendingBatch = { operations, signal, resolve };
+      ConduitBatcher.batchQueue.push(entry);
+ 
+      const cleanup = () => {
+        ConduitBatcher.activeCallbacks.delete(cleanup);
+      };
+      ConduitBatcher.activeCallbacks.add(cleanup);
+ 
+      Eif (!ConduitBatcher.processingBatch) {
+        ConduitBatcher.processQueue();
+      }
+ 
+      cleanup();
+    });
+  }
+ 
+  private static async processQueue(): Promise<void> {
+    Iif (ConduitBatcher.processingBatch || ConduitBatcher.isDestroyed) return;
+    ConduitBatcher.processingBatch = true;
+ 
+    while (ConduitBatcher.batchQueue.length > 0 && !ConduitBatcher.isDestroyed) {
+      const entry = ConduitBatcher.batchQueue.shift();
+      Iif (!entry) continue;
+ 
+      const { operations: ops, signal, resolve } = entry;
+ 
+      let cancelled = false;
+      const onAbort = () => { cancelled = true; };
+      if (signal) {
+        if (signal.aborted) {
+          cancelled = true;
+        } else {
+          signal.addEventListener('abort', onAbort);
+        }
+      }
+ 
+      try {
+        if (cancelled) {
+          resolve({ success: false, operations: 0, xdr: '', errors: ['Operation aborted'] });
+          continue;
+        }
+ 
+        const validationErrors = validatePayload(ops);
+        if (validationErrors.length > 0) {
+          resolve({
+            success: false,
+            operations: 0,
+            xdr: '',
+            errors: validationErrors,
+          });
+          continue;
+        }
+ 
+        const sanitized = ops.map(op => ({
+          ...op,
+          params: bigintSafeStringify(op.params),
+        }));
+ 
+        resolve({
+          success: true,
+          operations: sanitized.length,
+          xdr: "AAAA...mock...batch...XDR",
+        });
+      } finally {
+        if (signal && !cancelled) {
+          signal.removeEventListener('abort', onAbort);
+        }
+      }
+    }
+ 
+    ConduitBatcher.processingBatch = false;
+  }
+ 
+  /**
+   * Clean up all pending callbacks and reset state.
+   * Does NOT reset the destroyed flag — use destroy() for permanent teardown.
+   */
+  static cleanup(): void {
+    ConduitBatcher.processingBatch = false;
+ 
+    const oldQueue = ConduitBatcher.batchQueue;
+    ConduitBatcher.batchQueue = [];
+ 
+    oldQueue.forEach((entry) => {
+      entry.resolve({ success: false, operations: 0, xdr: '', errors: ['ConduitBatcher cleaned up'] });
+    });
+ 
+    for (const cb of ConduitBatcher.activeCallbacks) {
+      cb();
+    }
+    ConduitBatcher.activeCallbacks.clear();
+  }
+ 
+  /**
+   * Permanently destroy the batcher. All pending operations are rejected
+   * and subsequent calls to execute/executeAsync will throw.
+   */
+  static destroy(): void {
+    ConduitBatcher.isDestroyed = true;
+    ConduitBatcher.cleanup();
+  }
+ 
+  /**
+   * Full reset: clears destroyed flag and cleans up pending operations.
+   * Allows the batcher to be reused after destroy.
+   */
+  static reset(): void {
+    ConduitBatcher.isDestroyed = false;
+    ConduitBatcher.cleanup();
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/client.ts.html b/coverage/lcov-report/src/client.ts.html new file mode 100644 index 0000000..a443195 --- /dev/null +++ b/coverage/lcov-report/src/client.ts.html @@ -0,0 +1,418 @@ + + + + + + Code coverage report for src/client.ts + + + + + + + + + +
+
+

All files / src client.ts

+
+ +
+ 100% + Statements + 21/21 +
+ + +
+ 100% + Branches + 14/14 +
+ + +
+ 100% + Functions + 3/3 +
+ + +
+ 100% + Lines + 20/20 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112  +  +  +  +  +  +  +  +  +  +  +  +  +  +15x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +11x +11x +  +9x +9x +  +9x +1x +  +  +8x +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +26x +9x +  +  +17x +  +  +  +  +  +26x +3x +  +  +16x +16x +16x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +8x +8x +8x +  +  +  + 
import type { ConduitConfig } from './types/index.js';
+import type { WalletAdapter } from './adapters/types.js';
+import { DEFAULT_RPC }               from './soroban.js';
+import { StreamsModule }             from './streams.js';
+import { FactoryModule }             from './factory.js';
+import { GovernorModule }            from './governor.js';
+import { SUPPORTED_NETWORKS, UnsupportedChainError } from './errors.js';
+ 
+/**
+ * Map from CAIP-2 chain identifiers used by WalletConnect adapters to the
+ * canonical SDK network names they correspond to.  Any CAIP-2 value not
+ * present here is considered unsupported and will be rejected by
+ * `assertWalletNetworkMatch`.
+ */
+const CAIP2_TO_NETWORK: Readonly<Record<string, string>> = {
+  'stellar:pubnet':  'mainnet',
+  'stellar:testnet': 'testnet',
+  'stellar:local':   'local',
+};
+ 
+/**
+ * Validate that `wallet`'s network/chain matches the SDK's configured
+ * `network`.  Throws `UnsupportedChainError` when:
+ *
+ * - The wallet exposes a `chainId` property (WalletConnect CAIP-2 string)
+ *   that resolves to a network different from `expectedNetwork`.
+ * - The wallet exposes a `chainId` that is not in the supported CAIP-2
+ *   mapping at all (i.e. it's an EVM chain or an unknown Stellar variant).
+ *
+ * If the wallet has no `chainId` the check is skipped — adapters that do
+ * not expose chain information (e.g. `KeypairWalletAdapter`) are always
+ * accepted, because they sign whatever transaction the SDK constructs and
+ * never independently specify a chain.
+ */
+function assertWalletNetworkMatch(
+  wallet: WalletAdapter,
+  expectedNetwork: string,
+): void {
+  const raw = wallet.chainId;
+  if (raw === undefined || raw === null) return;
+ 
+  const caip2 = String(raw);
+  const resolved = CAIP2_TO_NETWORK[caip2];
+ 
+  if (resolved === undefined) {
+    throw new UnsupportedChainError(caip2);
+  }
+ 
+  if (resolved !== expectedNetwork) {
+    throw new UnsupportedChainError(
+      `${caip2} (wallet is on '${resolved}' but client is configured for '${expectedNetwork}')`,
+    );
+  }
+}
+ 
+export class ConduitClient {
+  readonly streams:  StreamsModule;
+  readonly factory:  FactoryModule;
+  readonly governor: GovernorModule;
+ 
+  private readonly config: Required<Pick<ConduitConfig, 'network' | 'rpcUrl'>> & ConduitConfig;
+ 
+  constructor(config: ConduitConfig) {
+    // Validate the network immediately so developers get a clear error at
+    // initialisation time rather than an obscure RPC failure later.
+    if (!(SUPPORTED_NETWORKS as readonly string[]).includes(config.network)) {
+      throw new UnsupportedChainError(config.network);
+    }
+ 
+    this.config = {
+      ...config,
+      rpcUrl: config.rpcUrl ?? DEFAULT_RPC[config.network],
+    };
+ 
+    // If a wallet is provided at construction time, validate its chain too.
+    if (this.config.wallet) {
+      assertWalletNetworkMatch(this.config.wallet, this.config.network);
+    }
+ 
+    this.streams  = new StreamsModule(this.config);
+    this.factory  = new FactoryModule(this.config);
+    this.governor = new GovernorModule(this.config);
+  }
+ 
+  /**
+   * Dynamically attach or change the active wallet adapter.
+   *
+   * Validates that the new wallet's chain matches the client's configured
+   * network before accepting it — prevents silent cross-chain mismatches
+   * from reaching the smart contract (fixes #157).
+   *
+   * **Wallet propagation contract:**
+   * - {@link StreamsModule}: Updated immediately — all subsequent stream
+   *   operations (create, withdraw, cancel, etc.) use the new wallet.
+   * - {@link FactoryModule}: NOT updated — this module is read-only and
+   *   uses `config.keypair` for simulation fee sourcing. It does not hold
+   *   a wallet reference and is unaffected by `setWallet()`.
+   * - {@link GovernorModule}: NOT updated — this module is read-only and
+   *   uses `config.keypair` for simulation fee sourcing. It does not hold
+   *   a wallet reference and is unaffected by `setWallet()`.
+   *
+   * @throws {UnsupportedChainError} if the wallet's `chainId` is on a
+   *   different network than the one this client was initialised with.
+   */
+  setWallet(wallet: WalletAdapter): void {
+    assertWalletNetworkMatch(wallet, this.config.network);
+    this.config.wallet = wallet;
+    this.streams.setWallet(wallet);
+  }
+}
+ 
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/constants.ts.html b/coverage/lcov-report/src/constants.ts.html new file mode 100644 index 0000000..8665ab8 --- /dev/null +++ b/coverage/lcov-report/src/constants.ts.html @@ -0,0 +1,109 @@ + + + + + + Code coverage report for src/constants.ts + + + + + + + + + +
+
+

All files / src constants.ts

+
+ +
+ 100% + Statements + 1/1 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 100% + Lines + 1/1 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9  +  +  +  +  +  +  +30x + 
/**
+ * A syntactically valid Stellar G-address with no known keypair. Used only
+ * as the transaction source for read-only simulation calls when no real
+ * keypair is configured — Soroban's simulateTransaction doesn't require the
+ * source account to actually exist or sign anything for a read-only
+ * invocation. Never used to sign or move funds.
+ */
+export const ZERO_ADDR = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF';
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/dashboard/index.html b/coverage/lcov-report/src/dashboard/index.html new file mode 100644 index 0000000..0449f1b --- /dev/null +++ b/coverage/lcov-report/src/dashboard/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for src/dashboard + + + + + + + + + +
+
+

All files src/dashboard

+
+ +
+ 94.61% + Statements + 158/167 +
+ + +
+ 91.94% + Branches + 194/211 +
+ + +
+ 100% + Functions + 20/20 +
+ + +
+ 96.37% + Lines + 133/138 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
transaction-history.ts +
+
94.61%158/16791.94%194/211100%20/2096.37%133/138
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/dashboard/transaction-history.ts.html b/coverage/lcov-report/src/dashboard/transaction-history.ts.html new file mode 100644 index 0000000..d1a5e0b --- /dev/null +++ b/coverage/lcov-report/src/dashboard/transaction-history.ts.html @@ -0,0 +1,1723 @@ + + + + + + Code coverage report for src/dashboard/transaction-history.ts + + + + + + + + + +
+
+

All files / src/dashboard transaction-history.ts

+
+ +
+ 94.61% + Statements + 158/167 +
+ + +
+ 91.94% + Branches + 194/211 +
+ + +
+ 100% + Functions + 20/20 +
+ + +
+ 96.37% + Lines + 133/138 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +23x +  +  +  +  +  +  +  +  +  +23x +  +  +  +  +  +  +  +  +  +23x +  +  +1x +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +262x +16x +15x +14x +  +  +  +34x +  +29x +  +5x +3x +3x +3x +3x +  +4x +  +  +  +4x +  +  +  +  +  +  +  +83x +80x +80x +  +  +  +  +  +  +  +9x +7x +4x +2x +2x +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +38x +  +30x +  +30x +38x +  +  +  +38x +  +27x +  +38x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +24x +  +24x +11x +11x +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +24x +  +15x +15x +  +15x +27x +27x +  +  +19x +17x +17x +  +  +15x +  +  +  +  +  +  +  +36x +36x +34x +34x +33x +33x +  +  +  +  +  +  +  +  +  +  +  +41x +  +41x +2x +  +  +39x +  +  +  +5x +  +  +9x +9x +  +  +  +9x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +4x +  +  +  +  +  +  +5x +5x +  +5x +2x +  +5x +  +  +5x +  +  +5x +2x +  +  +5x +  +  +  +  +  +  +  +  +5x +5x +  +  +  +5x +  +  +  +  +8x +  +  +  +8x +  +  +8x +  +  +  +2x +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +45x +45x +45x +  +45x +42x +42x +38x +38x +  +  +38x +  +9x +27x +  +  +  +  +  +  +  +  +22x +22x +  +22x +22x +22x +22x +  +22x +  +  +  +  +  +  +3x +3x +3x +  +  +  +  +  +  +  +  +  +  +12x +12x +  +12x +10x +7x +3x +  +  +  +  +  +  +  +  +8x +8x +5x +5x +4x +  +  +  +  +12x +12x +12x +12x +  +10x +10x +10x +12x +12x +  +12x +12x +  +12x +  +  +  +  +  +  +7x +7x +4x +4x +4x +  + 
/**
+ * Framework-agnostic state model for the Transaction History view.
+ *
+ * ## Why this module exists (#136 / #103)
+ *
+ * The Transaction History component crashed immediately on render because its
+ * state hook read variables that were never initialised:
+ *
+ * ```ts
+ * // BROKEN — `transactions` and `filters` are `undefined` on first render
+ * const [state, setState] = useState<HistoryState>();
+ * ...
+ * state.transactions.map(...)          // TypeError: Cannot read properties of
+ *                                      // undefined (reading 'map')
+ * ```
+ *
+ * Two distinct undefined-variable hazards were involved:
+ *
+ * 1. **Undefined initial state.** `useState()` / `useReducer(reducer)` invoked
+ *    without an initial value yields `undefined` on the very first render, so
+ *    every property access in the render body throws before the data ever
+ *    arrives from the indexer.
+ * 2. **Undefined payloads from the network.** Even with an initial value, the
+ *    GraphQL response may be `undefined` (in-flight, aborted by the 15s
+ *    timeout, or an `errorPolicy: 'all'` partial response), and individual
+ *    records may be missing `amount`, `counterparty`, `status`, etc.
+ *
+ * Both classes of failure are eliminated here rather than in the React layer:
+ * the state shape is *always* fully populated, every reducer transition is
+ * total (unknown actions return the previous state unchanged), and every
+ * selector is defensive about `null` / `undefined` / wrong-typed input. The
+ * React component is then a thin, crash-proof projection of this state.
+ *
+ * Keeping the logic outside the component also makes it testable in the SDK's
+ * Node-based vitest suite — `examples/dashboard` has no test runner and the
+ * root package has no React dependency (see the note on the reverted
+ * `token-selector-rendering.test.ts` in #159).
+ */
+ 
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+ 
+/** Lifecycle status of a single transaction row. */
+export type TransactionStatus =
+  | 'PENDING'
+  | 'CONFIRMED'
+  | 'FAILED'
+  | 'CANCELLED'
+  | 'UNKNOWN';
+ 
+/** Direction of value flow relative to the connected wallet. */
+export type TransactionDirection = 'IN' | 'OUT' | 'UNKNOWN';
+ 
+/** Transaction kinds surfaced by the indexer. */
+export type TransactionKind =
+  | 'CREATE'
+  | 'WITHDRAW'
+  | 'PAUSE'
+  | 'RESUME'
+  | 'CANCEL'
+  | 'TOP_UP'
+  | 'UNKNOWN';
+ 
+/**
+ * A normalised transaction record. Every field is non-optional: normalisation
+ * substitutes safe defaults so the render layer never has to null-check.
+ */
+export interface TransactionRecord {
+  id: string;
+  hash: string;
+  streamId: string;
+  kind: TransactionKind;
+  direction: TransactionDirection;
+  status: TransactionStatus;
+  /** Amount in stroops, kept as a string to avoid precision loss. */
+  amount: string;
+  asset: string;
+  counterparty: string;
+  /** Unix epoch milliseconds. `0` when the indexer omitted a timestamp. */
+  timestamp: number;
+}
+ 
+/** User-controlled filters applied client-side to the fetched page. */
+export interface TransactionFilters {
+  status: TransactionStatus | 'ALL';
+  kind: TransactionKind | 'ALL';
+  direction: TransactionDirection | 'ALL';
+  /** Free-text match against hash, stream id, counterparty and asset. */
+  search: string;
+}
+ 
+/** Complete Transaction History state. Never `undefined`, never partial. */
+export interface TransactionHistoryState {
+  transactions: TransactionRecord[];
+  filters: TransactionFilters;
+  page: number;
+  pageSize: number;
+  loading: boolean;
+  /** Human-readable error message, or `null` when there is no error. */
+  error: string | null;
+  /** Epoch ms of the last successful load; `0` before the first success. */
+  lastUpdated: number;
+}
+ 
+export type TransactionHistoryAction =
+  | { type: 'LOAD_START' }
+  | { type: 'LOAD_SUCCESS'; payload: unknown; receivedAt?: number }
+  | { type: 'LOAD_FAILURE'; error: unknown }
+  | { type: 'SET_FILTER'; filter: Partial<TransactionFilters> }
+  | { type: 'SET_PAGE'; page: unknown }
+  | { type: 'SET_PAGE_SIZE'; pageSize: unknown }
+  | { type: 'RESET' };
+ 
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+ 
+export const DEFAULT_PAGE_SIZE = 10;
+ 
+export const INITIAL_TRANSACTION_FILTERS: Readonly<TransactionFilters> =
+  Object.freeze({
+    status: 'ALL',
+    kind: 'ALL',
+    direction: 'ALL',
+    search: '',
+  });
+ 
+/**
+ * The single source of truth for the initial state.
+ *
+ * Passing this to `useReducer` (or `useState`) is what prevents the
+ * first-render crash: `state.transactions` is an array from the very first
+ * paint, before any network response exists.
+ */
+export function createInitialTransactionHistoryState(
+  overrides: Partial<TransactionHistoryState> = {},
+): TransactionHistoryState {
+  const base: TransactionHistoryState = {
+    transactions: [],
+    filters: { ...INITIAL_TRANSACTION_FILTERS },
+    page: 0,
+    pageSize: DEFAULT_PAGE_SIZE,
+    loading: false,
+    error: null,
+    lastUpdated: 0,
+  };
+ 
+  const merged: TransactionHistoryState = {
+    ...base,
+    ...overrides,
+    // Guard the two nested/array members against explicit `null` overrides.
+    transactions: Array.isArray(overrides.transactions)
+      ? overrides.transactions
+      : base.transactions,
+    filters: { ...base.filters, ...(overrides.filters ?? {}) },
+  };
+ 
+  return merged;
+}
+ 
+const VALID_STATUSES: readonly TransactionStatus[] = [
+  'PENDING',
+  'CONFIRMED',
+  'FAILED',
+  'CANCELLED',
+  'UNKNOWN',
+];
+ 
+const VALID_KINDS: readonly TransactionKind[] = [
+  'CREATE',
+  'WITHDRAW',
+  'PAUSE',
+  'RESUME',
+  'CANCEL',
+  'TOP_UP',
+  'UNKNOWN',
+];
+ 
+const VALID_DIRECTIONS: readonly TransactionDirection[] = [
+  'IN',
+  'OUT',
+  'UNKNOWN',
+];
+ 
+// ---------------------------------------------------------------------------
+// Coercion helpers — all total functions, never throw
+// ---------------------------------------------------------------------------
+ 
+function asString(value: unknown, fallback = ''): string {
+  if (typeof value === 'string') return value;
+  if (typeof value === 'number' && Number.isFinite(value)) return String(value);
+  if (typeof value === 'bigint') return value.toString();
+  return fallback;
+}
+ 
+function asTimestamp(value: unknown): number {
+  if (typeof value === 'number' && Number.isFinite(value)) {
+    // Indexers emit seconds for `createdAt`; normalise to milliseconds.
+    return value < 1e12 ? Math.trunc(value) * 1000 : Math.trunc(value);
+  }
+  if (typeof value === 'string' && value.trim() !== '') {
+    const numeric = Number(value);
+    Iif (Number.isFinite(numeric)) return asTimestamp(numeric);
+    const parsed = Date.parse(value);
+    if (!Number.isNaN(parsed)) return parsed;
+  }
+  Iif (value instanceof Date) {
+    const time = value.getTime();
+    return Number.isNaN(time) ? 0 : time;
+  }
+  return 0;
+}
+ 
+function asEnum<T extends string>(
+  value: unknown,
+  allowed: readonly T[],
+  fallback: T,
+): T {
+  if (typeof value !== 'string') return fallback;
+  const upper = value.trim().toUpperCase();
+  return (allowed as readonly string[]).includes(upper) ? (upper as T) : fallback;
+}
+ 
+/** Extracts a readable message from anything that was thrown or returned. */
+export function toErrorMessage(
+  error: unknown,
+  fallback = 'Failed to load transaction history.',
+): string {
+  if (typeof error === 'string' && error.trim() !== '') return error;
+  if (error instanceof Error && error.message.trim() !== '') return error.message;
+  if (error && typeof error === 'object') {
+    const message = (error as { message?: unknown }).message;
+    if (typeof message === 'string' && message.trim() !== '') return message;
+  }
+  return fallback;
+}
+ 
+// ---------------------------------------------------------------------------
+// Normalisation
+// ---------------------------------------------------------------------------
+ 
+/**
+ * Turns one raw indexer record into a fully-populated `TransactionRecord`.
+ * Returns `null` for values that cannot possibly be a record (`null`,
+ * primitives, arrays) so the caller can drop them.
+ */
+export function normalizeTransaction(raw: unknown): TransactionRecord | null {
+  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
+ 
+  const record = raw as Record<string, unknown>;
+ 
+  const id = asString(record['id'] ?? record['hash'] ?? record['streamId']);
+  const hash = asString(record['hash'] ?? record['txHash'] ?? record['id']);
+ 
+  // A record with no identity at all is unusable — drop it rather than
+  // rendering a row with an empty React key.
+  if (id === '' && hash === '') return null;
+ 
+  const amountRaw = record['amount'] ?? record['value'] ?? record['ratePerSecond'];
+ 
+  return {
+    id: id === '' ? hash : id,
+    hash,
+    streamId: asString(record['streamId'] ?? record['stream_id']),
+    kind: asEnum(record['kind'] ?? record['type'], VALID_KINDS, 'UNKNOWN'),
+    direction: asEnum(record['direction'], VALID_DIRECTIONS, 'UNKNOWN'),
+    status: asEnum(record['status'], VALID_STATUSES, 'UNKNOWN'),
+    amount: asString(amountRaw, '0'),
+    asset: asString(record['asset'] ?? record['token'] ?? record['symbol'], 'XLM'),
+    counterparty: asString(
+      record['counterparty'] ?? record['recipient'] ?? record['sender'],
+    ),
+    timestamp: asTimestamp(record['timestamp'] ?? record['createdAt']),
+  };
+}
+ 
+/**
+ * Normalises a whole payload. Accepts the raw GraphQL `data` object, a bare
+ * array, `null`, `undefined` or garbage — and always returns an array.
+ */
+export function normalizeTransactions(payload: unknown): TransactionRecord[] {
+  let list: unknown = payload;
+ 
+  if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
+    const container = payload as Record<string, unknown>;
+    list =
+      container['transactions'] ??
+      container['transactionHistory'] ??
+      container['streams'] ??
+      container['items'] ??
+      container['data'] ??
+      // Support the relay-style `{ edges: [{ node }] }` shape.
+      (Array.isArray(container['edges'])
+        ? (container['edges'] as unknown[]).map((edge) =>
+            edge && typeof edge === 'object'
+              ? (edge as Record<string, unknown>)['node']
+              : edge,
+          )
+        : undefined);
+  }
+ 
+  if (!Array.isArray(list)) return [];
+ 
+  const seen = new Set<string>();
+  const normalized: TransactionRecord[] = [];
+ 
+  for (const entry of list) {
+    const record = normalizeTransaction(entry);
+    if (record === null) continue;
+    // De-duplicate on id so a refetch racing a poll cannot produce duplicate
+    // React keys.
+    if (seen.has(record.id)) continue;
+    seen.add(record.id);
+    normalized.push(record);
+  }
+ 
+  return normalized;
+}
+ 
+// ---------------------------------------------------------------------------
+// Reducer
+// ---------------------------------------------------------------------------
+ 
+function clampPage(page: unknown, totalPages: number): number {
+  const numeric = typeof page === 'number' ? page : Number(page);
+  if (!Number.isFinite(numeric)) return 0;
+  const floored = Math.trunc(numeric);
+  if (floored < 0) return 0;
+  const maxPage = Math.max(0, totalPages - 1);
+  return floored > maxPage ? maxPage : floored;
+}
+ 
+/**
+ * Total reducer: every action returns a complete state object, and an
+ * unrecognised action (or an `undefined` state from a hot reload / persisted
+ * store) falls back to a valid state instead of propagating `undefined`.
+ */
+export function transactionHistoryReducer(
+  state: TransactionHistoryState | undefined,
+  action: TransactionHistoryAction | undefined,
+): TransactionHistoryState {
+  const current = state ?? createInitialTransactionHistoryState();
+ 
+  if (!action || typeof action !== 'object' || typeof action.type !== 'string') {
+    return current;
+  }
+ 
+  switch (action.type) {
+    case 'LOAD_START':
+      // Keep the existing rows visible while refreshing so the table does not
+      // flash empty; clear the previous error.
+      return { ...current, loading: true, error: null };
+ 
+    case 'LOAD_SUCCESS': {
+      const transactions = normalizeTransactions(action.payload);
+      const totalPages = Math.max(
+        1,
+        Math.ceil(transactions.length / Math.max(1, current.pageSize)),
+      );
+      return {
+        ...current,
+        transactions,
+        loading: false,
+        error: null,
+        page: clampPage(current.page, totalPages),
+        lastUpdated:
+          typeof action.receivedAt === 'number' && Number.isFinite(action.receivedAt)
+            ? action.receivedAt
+            : Date.now(),
+      };
+    }
+ 
+    case 'LOAD_FAILURE':
+      // Preserve any previously loaded rows: a failed refresh should degrade
+      // to "stale data + error banner", never to a crash or a blank screen.
+      return {
+        ...current,
+        loading: false,
+        error: toErrorMessage(action.error),
+      };
+ 
+    case 'SET_FILTER': {
+      const raw = action.filter ?? {};
+      const validated: Partial<TransactionFilters> = {};
+ 
+      if (raw.status !== undefined) {
+        validated.status = asEnum(raw.status, VALID_STATUSES, 'ALL') as TransactionStatus | 'ALL';
+      }
+      Iif (raw.kind !== undefined) {
+        validated.kind = asEnum(raw.kind, VALID_KINDS, 'ALL') as TransactionKind | 'ALL';
+      }
+      Iif (raw.direction !== undefined) {
+        validated.direction = asEnum(raw.direction, VALID_DIRECTIONS, 'ALL') as TransactionDirection | 'ALL';
+      }
+      if (raw.search !== undefined) {
+        validated.search = asString(raw.search);
+      }
+ 
+      return {
+        ...current,
+        filters: { ...current.filters, ...validated },
+        // Any filter change invalidates the current page offset.
+        page: 0,
+      };
+    }
+ 
+    case 'SET_PAGE': {
+      const visible = selectFilteredTransactions(current);
+      const totalPages = Math.max(
+        1,
+        Math.ceil(visible.length / Math.max(1, current.pageSize)),
+      );
+      return { ...current, page: clampPage(action.page, totalPages) };
+    }
+ 
+    case 'SET_PAGE_SIZE': {
+      const numeric =
+        typeof action.pageSize === 'number'
+          ? action.pageSize
+          : Number(action.pageSize);
+      const pageSize =
+        Number.isFinite(numeric) && numeric > 0
+          ? Math.trunc(numeric)
+          : DEFAULT_PAGE_SIZE;
+      return { ...current, pageSize, page: 0 };
+    }
+ 
+    case 'RESET':
+      return createInitialTransactionHistoryState();
+ 
+    default:
+      return current;
+  }
+}
+ 
+// ---------------------------------------------------------------------------
+// Selectors
+// ---------------------------------------------------------------------------
+ 
+/** Applies the active filters. Safe against a malformed/absent state. */
+export function selectFilteredTransactions(
+  state: TransactionHistoryState | undefined,
+): TransactionRecord[] {
+  const transactions = Array.isArray(state?.transactions) ? state.transactions : [];
+  const filters = { ...INITIAL_TRANSACTION_FILTERS, ...(state?.filters ?? {}) };
+  const search = asString(filters.search).trim().toLowerCase();
+ 
+  return transactions.filter((tx) => {
+    Iif (!tx) return false;
+    if (filters.status !== 'ALL' && tx.status !== filters.status) return false;
+    Iif (filters.kind !== 'ALL' && tx.kind !== filters.kind) return false;
+    Iif (filters.direction !== 'ALL' && tx.direction !== filters.direction) {
+      return false;
+    }
+    if (search === '') return true;
+ 
+    return [tx.hash, tx.streamId, tx.counterparty, tx.asset].some((field) =>
+      asString(field).toLowerCase().includes(search),
+    );
+  });
+}
+ 
+/** Filtered rows, newest first, sliced to the current page. */
+export function selectVisibleTransactions(
+  state: TransactionHistoryState | undefined,
+): TransactionRecord[] {
+  const filtered = selectFilteredTransactions(state);
+  const sorted = [...filtered].sort((a, b) => b.timestamp - a.timestamp);
+ 
+  const pageSize = Math.max(1, Math.trunc(state?.pageSize ?? DEFAULT_PAGE_SIZE));
+  const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
+  const page = clampPage(state?.page ?? 0, totalPages);
+  const start = page * pageSize;
+ 
+  return sorted.slice(start, start + pageSize);
+}
+ 
+/** Total number of pages for the current filters (always >= 1). */
+export function selectTotalPages(
+  state: TransactionHistoryState | undefined,
+): number {
+  const filtered = selectFilteredTransactions(state);
+  const pageSize = Math.max(1, Math.trunc(state?.pageSize ?? DEFAULT_PAGE_SIZE));
+  return Math.max(1, Math.ceil(filtered.length / pageSize));
+}
+ 
+/**
+ * Which of the mutually exclusive UI states the view should render.
+ * Centralised so the component cannot accidentally render a data table with
+ * no data (the original crash path).
+ */
+export function selectViewStatus(
+  state: TransactionHistoryState | undefined,
+): 'loading' | 'error' | 'empty' | 'ready' {
+  const current = state ?? createInitialTransactionHistoryState();
+  const hasRows = selectFilteredTransactions(current).length > 0;
+ 
+  if (current.loading && !hasRows) return 'loading';
+  if (current.error !== null && !hasRows) return 'error';
+  if (!hasRows) return 'empty';
+  return 'ready';
+}
+ 
+// ---------------------------------------------------------------------------
+// Display formatters
+// ---------------------------------------------------------------------------
+ 
+/** `GABC…WXYZ` — never throws on short, empty or non-string input. */
+export function formatAddress(address: unknown, visible = 6): string {
+  const value = asString(address);
+  if (value === '') return '—';
+  const head = Math.max(2, Math.trunc(visible));
+  if (value.length <= head + 4 + 1) return value;
+  return `${value.slice(0, head)}…${value.slice(-4)}`;
+}
+ 
+/** Stroops → decimal string. Falls back to `'0'` on unparseable input. */
+export function formatAmount(amount: unknown, decimals = 7): string {
+  const raw = asString(amount, '0').trim();
+  const negative = raw.startsWith('-');
+  const digits = (negative ? raw.slice(1) : raw).replace(/[^0-9]/g, '');
+  if (digits === '') return '0';
+ 
+  const places = Math.max(0, Math.trunc(decimals));
+  const padded = digits.padStart(places + 1, '0');
+  const whole = padded.slice(0, padded.length - places) || '0';
+  const fraction = places === 0 ? '' : padded.slice(padded.length - places);
+  const trimmedFraction = fraction.replace(/0+$/, '');
+ 
+  const formattedWhole = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+  const sign = negative ? '-' : '';
+ 
+  return trimmedFraction === ''
+    ? `${sign}${formattedWhole}`
+    : `${sign}${formattedWhole}.${trimmedFraction}`;
+}
+ 
+/** ISO-ish timestamp for display; `'—'` when the indexer omitted one. */
+export function formatTimestamp(timestamp: unknown): string {
+  const ms = asTimestamp(timestamp);
+  if (ms === 0) return '—';
+  const date = new Date(ms);
+  Iif (Number.isNaN(date.getTime())) return '—';
+  return date.toISOString().replace('T', ' ').slice(0, 19);
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/errors.ts.html b/coverage/lcov-report/src/errors.ts.html new file mode 100644 index 0000000..4728103 --- /dev/null +++ b/coverage/lcov-report/src/errors.ts.html @@ -0,0 +1,895 @@ + + + + + + Code coverage report for src/errors.ts + + + + + + + + + +
+
+

All files / src errors.ts

+
+ +
+ 95.4% + Statements + 83/87 +
+ + +
+ 86.84% + Branches + 33/38 +
+ + +
+ 100% + Functions + 11/11 +
+ + +
+ 100% + Lines + 82/82 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +39x +  +  +  +39x +39x +39x +39x +39x +39x +39x +39x +39x +39x +39x +39x +39x +39x +39x +39x +  +  +39x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +39x +39x +39x +39x +39x +39x +39x +39x +39x +39x +39x +  +  +39x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +39x +39x +39x +39x +  +  +39x +  +  +  +  +  +39x +  +  +  +  +  +  +  +  +  +  +  +39x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +29x +29x +  +  +  +29x +29x +29x +  +29x +  +  +  +  +  +  +  +  +50x +50x +50x +50x +  +  +  +  +  +  +  +  +2x +  +  +  +  +8x +7x +7x +6x +  +  +2x +  +  +  +  +  +  +  +  +  +  +7x +7x +5x +5x +5x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +8x +8x +8x +8x +  +  +  +6x +  +6x +6x +  +6x +6x +3x +  +  +3x +3x +2x +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +16x +  +16x +16x +6x +6x +6x +  +  +  +  +  +  +  +  +10x +10x +2x +  +  +8x +  +  + 
/**
+ * Each of the three Conduit contracts defines its own `Error` enum in Rust —
+ * the same numeric code means something different in each one (e.g. code 1
+ * is `NotAuthorized` on DripStream/DripGovernor, but `NotInitialized` on
+ * DripFactory). See each contract's README error table / errors.rs. Treating
+ * these as a single shared number space (as this module used to) means a
+ * Factory `NotInitialized` gets silently reported as `NotAuthorized`.
+ */
+export type ConduitContract = 'stream' | 'factory' | 'governor';
+ 
+/**
+ * Sentinel value used by {@link ConduitError.fromContractError} when the
+ * contract error code does not match any known code in the SDK's error
+ * catalogue. Use this constant (or {@link ConduitError.isKnown}) instead
+ * of hardcoding `-1` in consumer code.
+ */
+export const UNKNOWN_CONTRACT_ERROR_CODE = -1;
+ 
+// ── DripStream errors (contracts/stream/src/errors.rs) ────────────────────────
+ 
+export enum StreamErrorCode {
+  NotAuthorized        = 1,
+  StreamNotFound       = 2,
+  StreamCancelled      = 3,
+  StreamNotStarted     = 4,
+  StreamEnded          = 5,
+  NothingToWithdraw    = 6,
+  InsufficientDeposit  = 7,
+  InvalidTimeRange     = 8,
+  AlreadyPaused        = 9,
+  NotPaused            = 10,
+  ClawbackDisabled     = 11,
+  ArithmeticOverflow   = 12,
+  PauseThresholdNotMet = 13,
+  AlreadyInitialized   = 14,
+  InvalidAmount        = 15,
+}
+ 
+const STREAM_MESSAGES: Record<StreamErrorCode, string> = {
+  [StreamErrorCode.NotAuthorized]:        'Caller is not the sender or recipient of this stream.',
+  [StreamErrorCode.StreamNotFound]:       'Stream not found.',
+  [StreamErrorCode.StreamCancelled]:      'This stream has been cancelled.',
+  [StreamErrorCode.StreamNotStarted]:     'Stream has not started yet.',
+  [StreamErrorCode.StreamEnded]:          'Stream has passed its end time.',
+  [StreamErrorCode.NothingToWithdraw]:    'Nothing to withdraw — balance is zero.',
+  [StreamErrorCode.InsufficientDeposit]:  'Deposit is too small for the requested stream duration.',
+  [StreamErrorCode.InvalidTimeRange]:     'end_time must be greater than start_time.',
+  [StreamErrorCode.AlreadyPaused]:        'Stream is already paused.',
+  [StreamErrorCode.NotPaused]:            'Stream is not currently paused.',
+  [StreamErrorCode.ClawbackDisabled]:     'Clawback was not enabled when this stream was created.',
+  [StreamErrorCode.ArithmeticOverflow]:   'Integer overflow in stream calculation.',
+  [StreamErrorCode.PauseThresholdNotMet]: 'force_cancel called before the 30-day pause threshold elapsed.',
+  [StreamErrorCode.AlreadyInitialized]:   'Stream has already been initialized.',
+  [StreamErrorCode.InvalidAmount]:        'Amount must be greater than zero.',
+};
+ 
+// ── DripFactory errors (contracts/factory/src/errors.rs) ──────────────────────
+ 
+export enum FactoryErrorCode {
+  NotInitialized      = 1,
+  InvalidDeposit      = 2,
+  InvalidRate         = 3,
+  InvalidTimeRange    = 4,
+  InsufficientDeposit = 5,
+  BackdatedStream     = 6,
+  AlreadyInitialized  = 7,
+  RateExceedsMax      = 8,
+  DurationTooShort    = 9,
+  ArithmeticOverflow  = 10,
+}
+ 
+const FACTORY_MESSAGES: Record<FactoryErrorCode, string> = {
+  [FactoryErrorCode.NotInitialized]:      'Factory has not been initialized.',
+  [FactoryErrorCode.InvalidDeposit]:      'Deposit must be greater than zero.',
+  [FactoryErrorCode.InvalidRate]:         'rate_per_sec must be greater than zero.',
+  [FactoryErrorCode.InvalidTimeRange]:    'end_time must be greater than start_time.',
+  [FactoryErrorCode.InsufficientDeposit]: "Deposit doesn't cover rate_per_sec for at least one second, or the full declared duration.",
+  [FactoryErrorCode.BackdatedStream]:     'start_time cannot be in the past.',
+  [FactoryErrorCode.AlreadyInitialized]:  'Factory has already been initialized.',
+  [FactoryErrorCode.RateExceedsMax]:      "rate_per_sec exceeds the governor's max_rate_per_second.",
+  [FactoryErrorCode.DurationTooShort]:    "Stream duration is below the governor's min_duration_seconds.",
+  [FactoryErrorCode.ArithmeticOverflow]:  'Integer overflow validating deposit against duration.',
+};
+ 
+// ── DripGovernor errors (contracts/governor/src/errors.rs) ────────────────────
+ 
+export enum GovernorErrorCode {
+  NotAuthorized      = 1,
+  InvalidParam       = 2,
+  AlreadyInitialized = 3,
+}
+ 
+const GOVERNOR_MESSAGES: Record<GovernorErrorCode, string> = {
+  [GovernorErrorCode.NotAuthorized]:      'Caller is not the current governor authority.',
+  [GovernorErrorCode.InvalidParam]:       'Parameter failed validation (e.g. fee_bps > 10_000, zero duration/rate).',
+  [GovernorErrorCode.AlreadyInitialized]: 'Governor has already been initialized.',
+};
+ 
+const MESSAGES_BY_CONTRACT: Record<ConduitContract, Record<number, string>> = {
+  stream:   STREAM_MESSAGES,
+  factory:  FACTORY_MESSAGES,
+  governor: GOVERNOR_MESSAGES,
+};
+ 
+// ── Network / chain validation ─────────────────────────────────────────────────
+ 
+/**
+ * The exhaustive list of network identifiers the Conduit SDK supports.
+ * Matches the `Network` type in `src/types/index.ts`.
+ */
+export const SUPPORTED_NETWORKS = ['mainnet', 'testnet', 'local'] as const;
+ 
+/**
+ * Thrown synchronously by `ConduitClient` constructor when an unrecognised
+ * network string is supplied — before any RPC connection is attempted.
+ *
+ * @example
+ * ```ts
+ * try {
+ *   const client = new ConduitClient({ network: 'ropsten' as any });
+ * } catch (err) {
+ *   if (err instanceof UnsupportedChainError) {
+ *     console.error(err.message);           // "Unsupported network: 'ropsten'. ..."
+ *     console.error(err.providedNetwork);   // "ropsten"
+ *     console.error(err.supportedNetworks); // ["mainnet", "testnet", "local"]
+ *   }
+ * }
+ * ```
+ */
+export class UnsupportedChainError extends Error {
+  /** The network value the caller passed in. */
+  readonly providedNetwork: string;
+  /** The full list of accepted network identifiers at the time of the throw. */
+  readonly supportedNetworks: readonly string[];
+ 
+  constructor(providedNetwork: string) {
+    const supported = SUPPORTED_NETWORKS.join(', ');
+    super(
+      `Unsupported network: '${providedNetwork}'. ` +
+      `The Conduit SDK currently supports: ${supported}.`,
+    );
+    this.name = 'UnsupportedChainError';
+    this.providedNetwork    = providedNetwork;
+    this.supportedNetworks  = SUPPORTED_NETWORKS;
+    // Maintain correct prototype chain for `instanceof` checks in transpiled JS.
+    Object.setPrototypeOf(this, new.target.prototype);
+  }
+}
+ 
+export class ConduitError extends Error {
+  readonly contract: ConduitContract;
+  readonly code: number;
+ 
+  constructor(contract: ConduitContract, code: number, detail?: string) {
+    super(detail ?? MESSAGES_BY_CONTRACT[contract][code] ?? `ConduitError(${contract}, #${code})`);
+    this.name = 'ConduitError';
+    this.contract = contract;
+    this.code = code;
+  }
+ 
+  /**
+   * Returns `true` when this error carries a known contract error code
+   * (i.e. the code exists in the SDK's error catalogue for this contract).
+   * Returns `false` when the code is {@link UNKNOWN_CONTRACT_ERROR_CODE}.
+   */
+  get isKnown(): boolean {
+    return this.code in MESSAGES_BY_CONTRACT[this.contract];
+  }
+ 
+  /** Builds a ConduitError from a raw `{ code: number }`-shaped contract error object. */
+  static fromContractError(contract: ConduitContract, raw: unknown): ConduitError {
+    if (raw && typeof raw === 'object' && 'code' in raw) {
+      const code = Number((raw as { code: unknown }).code);
+      if (code in MESSAGES_BY_CONTRACT[contract]) {
+        return new ConduitError(contract, code);
+      }
+    }
+    return new ConduitError(contract, UNKNOWN_CONTRACT_ERROR_CODE, String(raw));
+  }
+ 
+  /**
+   * Parses a Soroban simulation/transaction failure message (e.g.
+   * `"HostError: Error(Contract, #7)"`) into a typed ConduitError scoped to
+   * `contract`. Returns a plain Error (not a ConduitError) if no contract
+   * error code can be extracted — network failures, timeouts, and non-contract
+   * host traps don't carry one.
+   */
+  static fromSorobanMessage(contract: ConduitContract, message: string): Error {
+    const match = /Error\(Contract,\s*#(\d+)\)/.exec(message);
+    if (!match || !match[1]) return new Error(message);
+    const code = Number(match[1]);
+    Iif (!(code in MESSAGES_BY_CONTRACT[contract])) return new Error(message);
+    return new ConduitError(contract, code, `${MESSAGES_BY_CONTRACT[contract][code]} (${message})`);
+  }
+}
+ 
+ 
+/**
+ * Thrown when an RPC node responds with HTTP 429 (Too Many Requests) or
+ * an equivalent JSON-RPC rate-limit error code, instead of the previous
+ * generic/unclassified error that made rate limiting indistinguishable
+ * from any other network failure. See #120.
+ */
+export class RateLimitError extends Error {
+  /** Milliseconds to wait before retrying, parsed from a Retry-After header if present. */
+  readonly retryAfterMs: number | undefined;
+ 
+  constructor(message: string, retryAfterMs?: number) {
+    super(message);
+    this.name = 'RateLimitError';
+    this.retryAfterMs = retryAfterMs;
+    Object.setPrototypeOf(this, new.target.prototype);
+  }
+ 
+  private static parseRetryAfterMs(raw: unknown): number | undefined {
+    Iif (typeof raw !== 'string' && typeof raw !== 'number') return undefined;
+ 
+    const value = String(raw).trim();
+    Iif (!value) return undefined;
+ 
+    const delaySeconds = Number(value);
+    if (Number.isFinite(delaySeconds) && delaySeconds >= 0) {
+      return delaySeconds * 1000;
+    }
+ 
+    const retryAt = Date.parse(value);
+    if (!Number.isNaN(retryAt)) {
+      return Math.max(0, retryAt - Date.now());
+    }
+ 
+    return undefined;
+  }
+ 
+  /**
+   * Detects a rate-limit condition from a raw error thrown by the RPC
+   * client and converts it into a typed RateLimitError. Handles two shapes:
+   *
+   * 1. An axios-style error (network-level 429), shaped like
+   *    `{ response: { status: 429, headers } }`.
+   * 2. A raw JSON-RPC error object (rpc/jsonrpc.js does `throw response.data.error`
+   *    directly, so it is a plain object, not an Error instance), shaped
+   *    like `{ code: -32029 }`.
+   *
+   * Returns null if `raw` is not a rate-limit error, so callers can fall
+   * back to their existing error handling.
+   */
+  static fromRpcError(raw: unknown): RateLimitError | null {
+    Iif (!raw || typeof raw !== 'object') return null;
+ 
+    const response = (raw as { response?: { status?: number; headers?: Record<string, unknown> } }).response;
+    if (response?.status === 429) {
+      const retryAfterHeader = response.headers?.['retry-after'];
+      const retryAfterMs = RateLimitError.parseRetryAfterMs(retryAfterHeader);
+      return new RateLimitError(
+        'RPC node rate limit exceeded (429 Too Many Requests). Back off and retry.',
+        retryAfterMs,
+      );
+    }
+ 
+    // De facto "too many requests" JSON-RPC error code used by several
+    // Soroban RPC providers, plus a literal 429 in case a provider reuses
+    // the HTTP status as the JSON-RPC error code.
+    const code = (raw as { code?: unknown }).code;
+    if (code === 429 || code === -32029) {
+      return new RateLimitError('RPC node rate limit exceeded (JSON-RPC error). Back off and retry.');
+    }
+ 
+    return null;
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/events.ts.html b/coverage/lcov-report/src/events.ts.html new file mode 100644 index 0000000..f6fd146 --- /dev/null +++ b/coverage/lcov-report/src/events.ts.html @@ -0,0 +1,784 @@ + + + + + + Code coverage report for src/events.ts + + + + + + + + + +
+
+

All files / src events.ts

+
+ +
+ 90.66% + Statements + 68/75 +
+ + +
+ 70.45% + Branches + 31/44 +
+ + +
+ 100% + Functions + 9/9 +
+ + +
+ 100% + Lines + 64/64 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +5x +  +  +  +10x +10x +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +10x +10x +10x +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +8x +8x +8x +8x +  +  +  +10x +  +10x +10x +  +  +  +  +  +  +  +  +9x +  +2x +2x +  +2x +2x +  +  +  +  +1x +  +  +10x +  +  +  +8x +  +8x +  +8x +8x +8x +8x +  +  +  +  +  +  +8x +  +  +  +  +  +  +  +  +  +  +  +  +  +12x +12x +  +10x +  +12x +  +12x +  +2x +  +1x +1x +  +  +  +  +  +1x +1x +  +  +  +2x +  +2x +2x +  +  +  +  +2x +2x +  +  +  +1x +  +1x +1x +  +  +  +  +1x +1x +  +  +  +1x +  +  +1x +  +  +  +1x +1x +  +  +  +1x +  +1x +1x +  +  +  +  +1x +1x +  +  +  +2x +  +2x +  +  +  +2x +2x +  +  +  + 
/**
+ * Event subscription for DripStream contracts.
+ *
+ * Polls the Soroban event ledger on an interval and dispatches typed events
+ * to registered handlers.
+ */
+ 
+import { Address, SorobanRpc, xdr } from '@stellar/stellar-sdk';
+import type {
+  StreamEventHandlers,
+  Subscription,
+  WithdrawEvent,
+  CancelEvent,
+  PauseEvent,
+  ResumeEvent,
+  TopUpEvent,
+  ClawbackEvent,
+} from './types/index.js';
+import { scValToI128, scValToU64, getServer } from './soroban.js';
+ 
+// ── Event topic names (match symbol_short!() values in Rust) ─────────────────
+ 
+const TOPIC = {
+  WITHDRAWN: 'withdrawn',
+  CANCELLED: 'cancelled',
+  PAUSED:    'paused',
+  RESUMED:   'resumed',
+  TOPPED_UP: 'topped_up',
+  CLAWBACK:  'clawback',
+} as const;
+ 
+// ── Parser helpers ────────────────────────────────────────────────────────────
+ 
+/**
+ * The stream contract publishes multi-field event data as a Rust tuple,
+ * which soroban-sdk encodes as an ScVec. Single-field events (resumed,
+ * clawback) publish the bare scalar instead — callers must know which shape
+ * to expect for a given topic (see contracts/stream/src/events.rs).
+ */
+function tupleFields(val: xdr.ScVal): xdr.ScVal[] {
+  return val.vec() ?? [];
+}
+ 
+function i128Field(fields: xdr.ScVal[], index: number): bigint {
+  const field = fields[index];
+  return field ? scValToI128(field) : 0n;
+}
+ 
+function u64Field(fields: xdr.ScVal[], index: number): number {
+  const field = fields[index];
+  return field ? Number(scValToU64(field)) : 0;
+}
+ 
+/**
+ * Decodes an address topic to its G.../C... string. `ScVal.address()?.accountId()`
+ * returns the raw XDR PublicKey object, not a string — calling `.toString()`
+ * on it yields `"[object Object]"`. `Address.fromScVal` handles both account
+ * and contract address variants correctly.
+ */
+function addressField(val: xdr.ScVal | undefined): string {
+  Iif (!val) return '';
+  try {
+    return Address.fromScVal(val).toString();
+  } catch {
+    return '';
+  }
+}
+ 
+// ── Subscription ──────────────────────────────────────────────────────────────
+ 
+/**
+ * Subscribe to on-chain events for a specific DripStream contract.
+ *
+ * @param rpcUrl        Soroban RPC endpoint
+ * @param streamAddress DripStream contract address (C…)
+ * @param handlers      Event handler callbacks
+ * @returns             `{ unsubscribe }` — call to stop polling
+ */
+export function subscribeToStream(
+  rpcUrl:        string,
+  streamAddress: string,
+  handlers:      StreamEventHandlers,
+): Subscription {
+  const server       = getServer(rpcUrl);
+  const pollInterval = handlers.pollInterval ?? 5000;
+  let   startLedger  = 0;  // updated after each successful poll
+  let   stopped      = false;
+  let   timer: ReturnType<typeof setTimeout> | undefined;
+ 
+  async function poll() {
+    Iif (stopped) return;
+ 
+    try {
+      const response = await server.getEvents({
+        ...(startLedger > 0 ? { startLedger } : {}),
+        filters: [{
+          type:        'contract',
+          contractIds: [streamAddress],
+        }],
+        limit: 100,
+      });
+ 
+      if (response.events.length > 0) {
+        // Update startLedger to avoid replaying events
+        const maxLedger = Math.max(...response.events.map(e => e.ledger));
+        startLedger = maxLedger + 1;
+ 
+        for (const event of response.events) {
+          dispatchEvent(event, handlers);
+        }
+      }
+    } catch (err) {
+      // Swallow polling errors; the subscription continues
+      console.warn('[conduit-sdk] event polling error:', err);
+    }
+ 
+    Eif (!stopped) timer = setTimeout(poll, pollInterval);
+  }
+ 
+  // Start polling immediately
+  poll();
+ 
+  return {
+    unsubscribe: () => {
+      stopped = true;
+      Eif (timer !== undefined) {
+        clearTimeout(timer);
+        timer = undefined;
+      }
+      // Release closure references to prevent memory leaks — without this,
+      // the handlers object, server instance, and everything they reference
+      // stay alive in the event loop until the JS engine garbage-collects
+      // the entire closure graph, which may never happen if any external
+      // reference to the Subscription object is kept.
+      handlers = {};
+    },
+  };
+}
+ 
+// ── Event dispatcher ──────────────────────────────────────────────────────────
+ 
+// Exported (but not re-exported from index.ts) so tests can exercise the
+// tuple-decoding logic directly without standing up a fake RPC server.
+export function dispatchEvent(
+  event:    SorobanRpc.Api.EventResponse,
+  handlers: StreamEventHandlers,
+): void {
+  // Topics: [symbol, actor_address]
+  const topics = event.topic;
+  if (!topics || topics.length < 1) return;
+ 
+  const topicName = topics[0]?.sym()?.toString() ?? '';
+ 
+  const actor = addressField(topics[1]);
+ 
+  switch (topicName) {
+    case TOPIC.WITHDRAWN: {
+      if (!handlers.onWithdraw) break;
+      // data: (amount: i128, total_withdrawn: i128, remaining: i128)
+      const fields = tupleFields(event.value);
+      const data: WithdrawEvent = {
+        recipient:      actor,
+        amount:         i128Field(fields, 0),
+        totalWithdrawn: i128Field(fields, 1),
+        remaining:      i128Field(fields, 2),
+      };
+      handlers.onWithdraw(data);
+      break;
+    }
+ 
+    case TOPIC.CANCELLED: {
+      Iif (!handlers.onCancel) break;
+      // data: (refund_amount: i128, withdrawn_so_far: i128)
+      const fields = tupleFields(event.value);
+      const data: CancelEvent = {
+        sender:         actor,
+        refundAmount:   i128Field(fields, 0),
+        withdrawnSoFar: i128Field(fields, 1),
+      };
+      handlers.onCancel(data);
+      break;
+    }
+ 
+    case TOPIC.PAUSED: {
+      Iif (!handlers.onPause) break;
+      // data: (paused_at: u64, withdrawable: i128)
+      const fields = tupleFields(event.value);
+      const data: PauseEvent = {
+        sender:       actor,
+        pausedAt:     u64Field(fields, 0),
+        withdrawable: i128Field(fields, 1),
+      };
+      handlers.onPause(data);
+      break;
+    }
+ 
+    case TOPIC.RESUMED: {
+      Iif (!handlers.onResume) break;
+      // data: resumed_at: u64 (bare scalar, not a tuple — resumed() only
+      // publishes one field, see contracts/stream/src/events.rs)
+      const data: ResumeEvent = {
+        sender:    actor,
+        resumedAt: Number(scValToU64(event.value)),
+      };
+      handlers.onResume(data);
+      break;
+    }
+ 
+    case TOPIC.TOPPED_UP: {
+      Iif (!handlers.onTopUp) break;
+      // data: (amount: i128, new_balance: i128)
+      const fields = tupleFields(event.value);
+      const data: TopUpEvent = {
+        sender:     actor,
+        amount:     i128Field(fields, 0),
+        newBalance: i128Field(fields, 1),
+      };
+      handlers.onTopUp(data);
+      break;
+    }
+ 
+    case TOPIC.CLAWBACK: {
+      Iif (!handlers.onClawback) break;
+      // data: amount: i128 (bare scalar, not a tuple)
+      const data: ClawbackEvent = {
+        sender: actor,
+        amount: scValToI128(event.value),
+      };
+      handlers.onClawback(data);
+      break;
+    }
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/factory.ts.html b/coverage/lcov-report/src/factory.ts.html new file mode 100644 index 0000000..539db9f --- /dev/null +++ b/coverage/lcov-report/src/factory.ts.html @@ -0,0 +1,448 @@ + + + + + + Code coverage report for src/factory.ts + + + + + + + + + +
+
+

All files / src factory.ts

+
+ +
+ 94.11% + Statements + 32/34 +
+ + +
+ 93.75% + Branches + 15/16 +
+ + +
+ 100% + Functions + 8/8 +
+ + +
+ 96.77% + Lines + 30/31 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +49x +  +  +  +49x +1x +  +48x +49x +  +  +  +  +49x +1x +  +  +  +47x +  +  +47x +  +  +  +  +2x +  +  +  +2x +2x +  +  +  +  +2x +2x +  +  +  +  +2x +  +  +2x +1x +1x +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +3x +3x +  +  +  +  +1x +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +2x +  +  +  +2x +2x +  +  +  +4x +4x +4x +  +  + 
/**
+ * FactoryModule — DripFactory read queries.
+ */
+ 
+import { nativeToScVal, xdr, Address } from '@stellar/stellar-sdk';
+import type { ConduitConfig } from './types/index.js';
+import { ZERO_ADDR } from './constants.js';
+import {
+  buildContractCallTx,
+  simulateReadOnly,
+  scValToU64,
+  NETWORK_PASSPHRASE,
+  DEFAULT_RPC,
+} from './soroban.js';
+import { SUPPORTED_NETWORKS, UnsupportedChainError } from './errors.js';
+ 
+export class FactoryModule {
+  private readonly rpcUrl:      string;
+  private readonly passphrase:  string;
+  private readonly factoryId:   string;
+  private readonly callerAddr:  string;
+ 
+  constructor(private readonly config: ConduitConfig) {
+    // Guard against direct construction with an unsupported network, which
+    // would bypass the ConduitClient validation gate and produce a confusing
+    // StrKey error deep inside stellar-sdk (fixes #157).
+    if (!(SUPPORTED_NETWORKS as readonly string[]).includes(config.network)) {
+      throw new UnsupportedChainError(config.network);
+    }
+    this.rpcUrl     = config.rpcUrl     ?? DEFAULT_RPC[config.network];
+    this.passphrase = NETWORK_PASSPHRASE[config.network];
+    // There is no known default DripFactory deployment for any network —
+    // shipping a placeholder string here means callers who forget to set
+    // this fail deep inside @stellar/stellar-sdk with a confusing StrKey
+    // error instead of a clear one at construction time.
+    if (!config.factoryAddress) {
+      throw new Error(
+        `ConduitConfig.factoryAddress is required (no default DripFactory is known for network "${config.network}").`,
+      );
+    }
+    this.factoryId  = config.factoryAddress;
+    // For read-only calls we use the keypair's public key as the fee source;
+    // if no keypair, we use the zero address (simulation only — no real account needed).
+    this.callerAddr = config.keypair?.publicKey() ?? ZERO_ADDR;
+  }
+ 
+  /** Total number of streams ever created through this factory. */
+  async streamCount(): Promise<bigint> {
+    const tx  = await buildContractCallTx(
+      this.rpcUrl, this.passphrase, this.callerAddr,
+      this.factoryId, 'stream_count', [],
+    );
+    const val = await simulateReadOnly(this.rpcUrl, this.passphrase, tx);
+    return scValToU64(val);
+  }
+ 
+  /** Resolve a stream ID to its deployed contract address. Returns null if not found. */
+  async streamAddress(streamId: bigint | string): Promise<string | null> {
+    const id  = BigInt(streamId);
+    const tx  = await buildContractCallTx(
+      this.rpcUrl, this.passphrase, this.callerAddr,
+      this.factoryId, 'stream_address',
+      [nativeToScVal(id, { type: 'u64' })],
+    );
+    const val = await simulateReadOnly(this.rpcUrl, this.passphrase, tx);
+ 
+    // Contract returns Option<Address> — void = None
+    if (val.switch().name === 'scvVoid') return null;
+    try {
+      return Address.fromScVal(val).toString();
+    } catch {
+      return null;
+    }
+  }
+ 
+  /** List stream IDs where `address` is the sender, paginated. */
+  async streamsBySender(address: string, offset = 0, limit = 20): Promise<bigint[]> {
+    const tx  = await buildContractCallTx(
+      this.rpcUrl, this.passphrase, this.callerAddr,
+      this.factoryId, 'streams_by_sender',
+      [
+        new Address(address).toScVal(),
+        nativeToScVal(offset, { type: 'u32' }),
+        nativeToScVal(limit,  { type: 'u32' }),
+      ],
+    );
+    const val = await simulateReadOnly(this.rpcUrl, this.passphrase, tx);
+    return this.parseU64Vec(val);
+  }
+ 
+  /** List stream IDs where `address` is the recipient, paginated. */
+  async streamsByRecipient(address: string, offset = 0, limit = 20): Promise<bigint[]> {
+    const tx  = await buildContractCallTx(
+      this.rpcUrl, this.passphrase, this.callerAddr,
+      this.factoryId, 'streams_by_recipient',
+      [
+        new Address(address).toScVal(),
+        nativeToScVal(offset, { type: 'u32' }),
+        nativeToScVal(limit,  { type: 'u32' }),
+      ],
+    );
+    const val = await simulateReadOnly(this.rpcUrl, this.passphrase, tx);
+    return this.parseU64Vec(val);
+  }
+ 
+  /** Current protocol fee in basis points (e.g. 30 = 0.3%). */
+  async protocolFeeBps(): Promise<number> {
+    const tx  = await buildContractCallTx(
+      this.rpcUrl, this.passphrase, this.callerAddr,
+      this.factoryId, 'protocol_fee_bps', [],
+    );
+    const val = await simulateReadOnly(this.rpcUrl, this.passphrase, tx);
+    return Number(val.u32());
+  }
+ 
+  private parseU64Vec(val: xdr.ScVal): bigint[] {
+    const items = val.vec();
+    Iif (!items) return [];
+    return items.map(v => scValToU64(v));
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/fee-estimator.ts.html b/coverage/lcov-report/src/fee-estimator.ts.html new file mode 100644 index 0000000..80ec8c3 --- /dev/null +++ b/coverage/lcov-report/src/fee-estimator.ts.html @@ -0,0 +1,391 @@ + + + + + + Code coverage report for src/fee-estimator.ts + + + + + + + + + +
+
+

All files / src fee-estimator.ts

+
+ +
+ 100% + Statements + 34/34 +
+ + +
+ 100% + Branches + 19/19 +
+ + +
+ 100% + Functions + 8/8 +
+ + +
+ 100% + Lines + 34/34 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +16x +16x +  +16x +16x +  +  +16x +16x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +237x +217x +  +  +  +20x +5x +5x +1x +  +  +  +19x +19x +19x +19x +  +  +12x +1x +  +  +  +11x +11x +11x +11x +  +8x +8x +8x +  +  +8x +  +19x +19x +  +  +  +19x +  +  +  +  +1x +  +  +  +2x +  +  +  +3x +  +  +  +6x +  +  +  +7x +  +  + 
export interface FeeEstimatorOptions {
+  /**
+   * Minimum interval in milliseconds between network fetches.
+   * Within this window, `estimateFee` serves the cached `baseFee`
+   * instead of calling `networkFetcher` again. Default: no interval (every call fetches).
+   */
+  minRefetchIntervalMs?: number;
+}
+ 
+export interface FeeEstimateOptions {
+  onError?: (error: Error) => void;
+}
+ 
+export class FeeEstimator {
+  private baseFee: number;
+  private isEstimating: boolean = false;
+  private currentPromise: Promise<number> | null = null;
+  private readonly minRefetchIntervalMs: number;
+  private lastSuccessfulFetchAtValue: number | null = null;
+  private lastErrorValue: Error | null = null;
+ 
+  constructor(initialFee: number = 100, options?: FeeEstimatorOptions) {
+    this.baseFee = initialFee;
+    this.minRefetchIntervalMs = options?.minRefetchIntervalMs ?? 0;
+  }
+ 
+  /**
+   * Safely estimates the fee by fetching it asynchronously.
+   * Utilizes an atomic state transition / locking mechanism to prevent race conditions 
+   * when multiple async hooks fire simultaneously.
+   *
+   * If `minRefetchIntervalMs` was configured, returns the cached `baseFee` when
+   * called within that window after the last successful fetch.
+   */
+  async estimateFee(
+    networkFetcher: () => Promise<number>,
+    options: FeeEstimateOptions = {}
+  ): Promise<number> {
+    if (this.currentPromise) {
+      return this.currentPromise;
+    }
+ 
+    // Return cached fee if within the minimum re-fetch interval
+    if (this.minRefetchIntervalMs > 0) {
+      const elapsed = Date.now() - (this.lastSuccessfulFetchAtValue ?? 0);
+      if (elapsed < this.minRefetchIntervalMs) {
+        return this.baseFee;
+      }
+    }
+ 
+    this.currentPromise = (async () => {
+      try {
+        this.isEstimating = true;
+        const rawFee = await networkFetcher();
+        
+        // Ensure floating point math precision and error-boundary handler
+        if (typeof rawFee !== 'number' || !Number.isFinite(rawFee) || rawFee < 0) {
+            throw new Error("Invalid network fee response");
+        }
+        
+        // Round to 7 decimal places for precision handling
+        this.baseFee = Math.round(rawFee * 10000000) / 10000000;
+        this.lastSuccessfulFetchAtValue = Date.now();
+        this.lastErrorValue = null;
+        return this.baseFee;
+      } catch (error) {
+        const normalizedError = error instanceof Error ? error : new Error(String(error));
+        this.lastErrorValue = normalizedError;
+        options.onError?.(normalizedError);
+ 
+        // Fallback sequence: return the last known base fee
+        return this.baseFee;
+      } finally {
+        this.isEstimating = false;
+        this.currentPromise = null;
+      }
+    })();
+ 
+    return this.currentPromise;
+  }
+ 
+  // Exposed for testing internal state
+  get _isEstimating(): boolean {
+    return this.isEstimating;
+  }
+ 
+  getBaseFee(): number {
+    return this.baseFee;
+  }
+ 
+  get lastSuccessfulFetchAt(): number | null {
+    return this.lastSuccessfulFetchAtValue;
+  }
+ 
+  get lastError(): Error | null {
+    return this.lastErrorValue;
+  }
+ 
+  get isStale(): boolean {
+    return this.lastErrorValue !== null;
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/governor.ts.html b/coverage/lcov-report/src/governor.ts.html new file mode 100644 index 0000000..e038c6f --- /dev/null +++ b/coverage/lcov-report/src/governor.ts.html @@ -0,0 +1,316 @@ + + + + + + Code coverage report for src/governor.ts + + + + + + + + + +
+
+

All files / src governor.ts

+
+ +
+ 100% + Statements + 18/18 +
+ + +
+ 86.95% + Branches + 20/23 +
+ + +
+ 100% + Functions + 3/3 +
+ + +
+ 100% + Lines + 18/18 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +22x +1x +  +21x +22x +22x +22x +22x +  +  +  +  +4x +1x +  +  +  +3x +  +  +  +3x +3x +  +  +  +  +3x +3x +3x +7x +7x +  +3x +  +  +  +  +  +  +  +  +  +  + 
/**
+ * GovernorModule — DripGovernor config reads.
+ */
+ 
+import { Address, xdr } from '@stellar/stellar-sdk';
+import type { ConduitConfig, GovernorConfig } from './types/index.js';
+import { ZERO_ADDR } from './constants.js';
+import {
+  buildContractCallTx,
+  simulateReadOnly,
+  scValToI128,
+  scValToU64,
+  NETWORK_PASSPHRASE,
+  DEFAULT_RPC,
+} from './soroban.js';
+import { SUPPORTED_NETWORKS, UnsupportedChainError } from './errors.js';
+ 
+export class GovernorModule {
+  private readonly rpcUrl:      string;
+  private readonly passphrase:  string;
+  private readonly governorId: string | undefined;
+  private readonly callerAddr:  string;
+  private readonly network:     ConduitConfig['network'];
+ 
+  // Unlike FactoryModule (a hard prerequisite for virtually all StreamsModule
+  // methods), GovernorModule is orthogonal to stream operations — a caller
+  // using only client.streams shouldn't be forced to supply a
+  // governorAddress they'll never touch. ConduitClient constructs this
+  // module unconditionally, so the missing-address check has to be deferred
+  // to first actual use (getConfig()) rather than thrown in the constructor.
+  constructor(cfg: ConduitConfig) {
+    // Guard against direct construction with an unsupported network, which
+    // would bypass the ConduitClient validation gate (fixes #157).
+    if (!(SUPPORTED_NETWORKS as readonly string[]).includes(cfg.network)) {
+      throw new UnsupportedChainError(cfg.network);
+    }
+    this.rpcUrl     = cfg.rpcUrl ?? DEFAULT_RPC[cfg.network];
+    this.passphrase = NETWORK_PASSPHRASE[cfg.network];
+    this.governorId = cfg.governorAddress;
+    this.callerAddr = cfg.keypair?.publicKey() ?? ZERO_ADDR;
+    this.network    = cfg.network;
+  }
+ 
+  /** Fetch the current protocol config from the DripGovernor contract. */
+  async getConfig(): Promise<GovernorConfig> {
+    if (!this.governorId) {
+      throw new Error(
+        `ConduitConfig.governorAddress is required (no default DripGovernor is known for network "${this.network}").`,
+      );
+    }
+    const tx  = await buildContractCallTx(
+      this.rpcUrl, this.passphrase, this.callerAddr,
+      this.governorId, 'config', [],
+    );
+    const val = await simulateReadOnly(this.rpcUrl, this.passphrase, tx);
+    return parseGovernorConfig(val);
+  }
+}
+ 
+function parseGovernorConfig(val: xdr.ScVal): GovernorConfig {
+  const entries = val.map() ?? [];
+  const m: Record<string, xdr.ScVal> = {};
+  for (const e of entries) {
+    const k = e.key().sym()?.toString('utf8') ?? e.key().str()?.toString('utf8') ?? '';
+    m[k] = e.val();
+  }
+  return {
+    feeBps:             m['fee_bps']?.u32() ?? 0,
+    minDurationSeconds: m['min_duration_seconds'] ? Number(scValToU64(m['min_duration_seconds'])) : 0,
+    maxRatePerSecond:   m['max_rate_per_second'] ? scValToI128(m['max_rate_per_second']) : 0n,
+    // exactOptionalPropertyTypes forbids assigning `undefined` to an optional
+    // key directly — the key must be entirely absent instead, hence the
+    // conditional spreads rather than `feeRecipient: ... ? ... : undefined`.
+    ...(m['fee_recipient'] ? { feeRecipient: Address.fromScVal(m['fee_recipient']).toString() } : {}),
+    ...(m['factory_address'] ? { factoryAddress: Address.fromScVal(m['factory_address']).toString() } : {}),
+  };
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/graph-sync-agent.ts.html b/coverage/lcov-report/src/graph-sync-agent.ts.html new file mode 100644 index 0000000..bfacb24 --- /dev/null +++ b/coverage/lcov-report/src/graph-sync-agent.ts.html @@ -0,0 +1,205 @@ + + + + + + Code coverage report for src/graph-sync-agent.ts + + + + + + + + + +
+
+

All files / src graph-sync-agent.ts

+
+ +
+ 100% + Statements + 15/15 +
+ + +
+ 100% + Branches + 5/5 +
+ + +
+ 100% + Functions + 5/5 +
+ + +
+ 100% + Lines + 14/14 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41  +  +5x +  +  +5x +  +  +  +  +  +  +  +  +155x +155x +  +153x +1x +  +  +  +152x +152x +152x +  +152x +  +152x +  +  +155x +  +155x +  +  +  +3x +  +  + 
export class GraphSyncAgent {
+  private currentState: number;
+  private updateQueue: Promise<void> = Promise.resolve();
+  
+  constructor(initialState: number = 0) {
+    this.currentState = initialState;
+  }
+ 
+  /**
+   * Safely synchronizes state with fractional updates from the network.
+   * Utilizes an atomic state transition / strict locking mechanism to prevent race conditions 
+   * and unhandled promise rejections under poor network conditions.
+   */
+  async syncState(networkFetcher: () => Promise<number>): Promise<number> {
+    const syncOperation = this.updateQueue.then(async () => {
+      const rawDelta = await networkFetcher();
+ 
+      if (typeof rawDelta !== 'number' || !Number.isFinite(rawDelta)) {
+        throw new Error("Invalid network delta response");
+      }
+ 
+      // Scale to 8 decimal places for safe fractional accumulation
+      const scale = 100000000;
+      const scaledState = Math.round(this.currentState * scale);
+      const scaledDelta = Math.round(rawDelta * scale);
+ 
+      this.currentState = (scaledState + scaledDelta) / scale;
+ 
+      return this.currentState;
+    });
+ 
+    this.updateQueue = syncOperation.catch(() => undefined);
+ 
+    return syncOperation;
+  }
+ 
+  getCurrentState(): number {
+    return this.currentState;
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/index.html b/coverage/lcov-report/src/index.html new file mode 100644 index 0000000..e5c4618 --- /dev/null +++ b/coverage/lcov-report/src/index.html @@ -0,0 +1,341 @@ + + + + + + Code coverage report for src + + + + + + + + + +
+
+

All files src

+
+ +
+ 96.26% + Statements + 1081/1123 +
+ + +
+ 89.98% + Branches + 728/809 +
+ + +
+ 98.28% + Functions + 172/175 +
+ + +
+ 98.1% + Lines + 1033/1053 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
builder.ts +
+
93.56%189/20295.03%134/14193.33%28/3094.89%186/196
client.ts +
+
100%21/21100%14/14100%3/3100%20/20
constants.ts +
+
100%1/1100%0/0100%0/0100%1/1
errors.ts +
+
95.4%83/8786.84%33/38100%11/11100%82/82
events.ts +
+
90.66%68/7570.45%31/44100%9/9100%64/64
factory.ts +
+
94.11%32/3493.75%15/16100%8/896.77%30/31
fee-estimator.ts +
+
100%34/34100%19/19100%8/8100%34/34
governor.ts +
+
100%18/1886.95%20/23100%3/3100%18/18
graph-sync-agent.ts +
+
100%15/15100%5/5100%5/5100%14/14
index.ts +
+
0%0/00%0/00%0/00%0/0
indexer.ts +
+
98.62%143/14593.19%137/147100%15/1599.27%137/138
nonce-manager.ts +
+
100%18/18100%5/5100%4/4100%18/18
signer.ts +
+
97.72%86/8887.77%79/90100%18/18100%83/83
soroban.ts +
+
100%65/65100%30/30100%13/13100%63/63
streams.ts +
+
95.86%232/24285%153/18097.43%38/3996.44%217/225
utils.ts +
+
97.43%76/7892.98%53/57100%9/9100%66/66
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/index.ts.html b/coverage/lcov-report/src/index.ts.html new file mode 100644 index 0000000..bab0495 --- /dev/null +++ b/coverage/lcov-report/src/index.ts.html @@ -0,0 +1,202 @@ + + + + + + Code coverage report for src/index.ts + + + + + + + + + +
+
+

All files / src index.ts

+
+ +
+ 0% + Statements + 0/0 +
+ + +
+ 0% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/0 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
export { ConduitClient } from './client.js';
+export { StreamBuilder, ConduitBatcher } from './builder.js';
+export { GraphQLIndexer } from './indexer.js';
+export { KeypairSigner } from './signer.js';
+export type { Signer } from './signer.js';
+export {
+  ConduitError,
+  StreamErrorCode,
+  FactoryErrorCode,
+  GovernorErrorCode,
+  UnsupportedChainError,
+  SUPPORTED_NETWORKS,
+  UNKNOWN_CONTRACT_ERROR_CODE,
+} from './errors.js';
+export type { ConduitContract } from './errors.js';
+export type { StreamConfig, StreamState, Recipient } from './types/index.js';
+export * from './types/index.js';
+export * from './adapters/index.js';
+export { FeeEstimator } from './fee-estimator.js';
+export type { FeeEstimateOptions } from './fee-estimator.js';
+export { WebSocketRelayer } from './relayer/WebSocketRelayer.js';
+export { ErrorMapper } from './relayer/ErrorMapper.js';
+export type { MappedErrorHandler } from './relayer/ErrorMapper.js';
+ 
+// Utils are exported via the /utils subpath export, but also available here
+export {
+  toStroops,
+  fromStroops,
+  calculateRate,
+  streamProgress,
+  withdrawableLocal,
+  bigintSafeStringify,
+} from './utils.js';
+ 
+export {
+  formatAddress,
+  formatAmount,
+  formatTimestamp,
+} from './dashboard/transaction-history.js';
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/indexer.ts.html b/coverage/lcov-report/src/indexer.ts.html new file mode 100644 index 0000000..0ed5df7 --- /dev/null +++ b/coverage/lcov-report/src/indexer.ts.html @@ -0,0 +1,982 @@ + + + + + + Code coverage report for src/indexer.ts + + + + + + + + + +
+
+

All files / src indexer.ts

+
+ +
+ 98.62% + Statements + 143/145 +
+ + +
+ 93.19% + Branches + 137/147 +
+ + +
+ 100% + Functions + 15/15 +
+ + +
+ 99.27% + Lines + 137/138 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +67x +67x +67x +  +  +67x +4x +  +63x +  +  +  +15x +1x +  +14x +2x +  +12x +3x +  +  +9x +15x +1x +  +  +8x +  +  +  +  +  +8x +15x +1x +  +  +7x +  +  +  +  +  +  +  +  +7x +2x +  +  +5x +  +  +  +46x +3x +  +43x +1x +  +42x +1x +  +41x +1x +  +  +40x +46x +1x +  +  +39x +39x +  +39x +39x +  +39x +  +42x +39x +  +39x +29x +29x +1x +  +29x +  +  +  +29x +  +  +39x +9x +9x +  +  +  +9x +  +  +39x +  +  +  +39x +  +39x +39x +30x +30x +  +30x +30x +  +2x +  +29x +  +29x +3x +1x +1x +  +2x +2x +2x +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +29x +9x +7x +7x +9x +  +5x +3x +3x +2x +1x +1x +1x +  +  +1x +  +  +  +29x +11x +1x +  +  +29x +12x +1x +  +  +  +1x +  +  +9x +9x +9x +9x +  +  +  +  +  +9x +  +  +  +  +  +  +8x +8x +1x +1x +  +7x +6x +6x +6x +6x +13x +13x +7x +7x +7x +13x +18x +18x +9x +9x +8x +8x +8x +  +  +  +  +  +  +  +  +  +1x +1x +1x +  +  +  +  +  +39x +  +  +  +16x +  +  +  +63x +63x +27x +  +63x +  +  +  +41x +31x +  +10x +  +  +10x +  +  +  +33x +31x +  +2x +1x +  +1x +  +  +  +12x +10x +10x +  +  +  +  +  +  + 
export interface GraphQLQueryOptions {
+  query: string;
+  variables?: Record<string, unknown>;
+  headers?: Record<string, string>;
+}
+ 
+export interface GraphQLSubscriptionOptions {
+  query: string;
+  variables?: Record<string, unknown>;
+  headers?: Record<string, string>;
+  onData: (data: unknown) => void;
+  onError?: (error: Error) => void;
+}
+ 
+export interface IndexerSubscription {
+  unsubscribe: () => void;
+}
+ 
+export class GraphQLIndexer {
+  private endpoint: string;
+  private activeSubscriptions: Set<IndexerSubscription> = new Set();
+  private isDestroyed = false;
+  private subCounter = 0;
+ 
+  constructor(endpoint: string) {
+    if (!endpoint || typeof endpoint !== 'string' || endpoint.trim().length === 0) {
+      throw new Error('GraphQLIndexer endpoint must be a non-empty string');
+    }
+    this.endpoint = endpoint;
+  }
+ 
+  async query(options: GraphQLQueryOptions): Promise<unknown> {
+    if (this.isDestroyed) {
+      throw new Error('GraphQLIndexer has been destroyed');
+    }
+    if (!options || typeof options !== 'object') {
+      throw new Error('GraphQLQueryOptions cannot be null or undefined');
+    }
+    if (!options.query || typeof options.query !== 'string' || options.query.trim().length === 0) {
+      throw new Error('GraphQL query string cannot be null or empty');
+    }
+ 
+    const variables = options.variables ?? {};
+    if (typeof variables !== 'object' || variables === null) {
+      throw new Error('GraphQL query variables must be an object');
+    }
+ 
+    const headers: Record<string, string> = {
+      'Content-Type': 'application/json',
+      'Accept': 'application/json',
+      ...options.headers,
+    };
+ 
+    const fetchFn = typeof fetch !== 'undefined' ? fetch : (globalThis as unknown as { fetch?: typeof fetch }).fetch;
+    if (typeof fetchFn !== 'function') {
+      throw new Error('Fetch API is not available in the current environment');
+    }
+ 
+    const response = await fetchFn(this.endpoint, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({
+        query: options.query,
+        variables,
+      }),
+    });
+ 
+    if (!response.ok) {
+      throw new Error(`GraphQL query failed with status ${response.status}: ${response.statusText}`);
+    }
+ 
+    return await response.json();
+  }
+ 
+  subscribe(options: GraphQLSubscriptionOptions): IndexerSubscription {
+    if (this.isDestroyed) {
+      throw new Error('GraphQLIndexer has been destroyed');
+    }
+    if (!options || typeof options !== 'object') {
+      throw new Error('GraphQLSubscriptionOptions cannot be null or undefined');
+    }
+    if (!options.query || typeof options.query !== 'string' || options.query.trim().length === 0) {
+      throw new Error('GraphQL subscription query string cannot be null or empty');
+    }
+    if (typeof options.onData !== 'function') {
+      throw new Error('GraphQL subscription onData callback must be a function');
+    }
+ 
+    const variables = options.variables ?? {};
+    if (typeof variables !== 'object' || variables === null) {
+      throw new Error('GraphQL query variables must be an object');
+    }
+ 
+    let unsubscribed = false;
+    const subId = `sub_${++this.subCounter}_${Date.now()}`;
+ 
+    let ws: WebSocket | null = null;
+    let abortController: AbortController | null = null;
+ 
+    const subscription: IndexerSubscription = {
+      unsubscribe: () => {
+        if (unsubscribed) return;
+        unsubscribed = true;
+ 
+        if (ws) {
+          try {
+            if (ws.readyState === 1 /* OPEN */) {
+              ws.send(JSON.stringify({ id: subId, type: 'complete' }));
+            }
+            ws.close();
+          } catch {
+            // Ignore socket closure errors
+          }
+          ws = null;
+        }
+ 
+        if (abortController) {
+          try {
+            abortController.abort();
+          } catch {
+            // Ignore abort error
+          }
+          abortController = null;
+        }
+ 
+        this.activeSubscriptions.delete(subscription);
+      },
+    };
+ 
+    this.activeSubscriptions.add(subscription);
+ 
+    const WebSocketCtor = this.getWebSocketCtor();
+    if (WebSocketCtor) {
+      try {
+        const wsUrl = this.getWsUrl(this.endpoint);
+        let socket: WebSocket;
+        try {
+          socket = new WebSocketCtor(wsUrl, 'graphql-transport-ws');
+        } catch {
+          socket = new WebSocketCtor(wsUrl);
+        }
+        ws = socket;
+ 
+        socket.onopen = () => {
+          if (unsubscribed || this.isDestroyed) {
+            subscription.unsubscribe();
+            return;
+          }
+          try {
+            socket.send(JSON.stringify({ type: 'connection_init' }));
+            socket.send(
+              JSON.stringify({
+                id: subId,
+                type: 'subscribe',
+                payload: {
+                  query: options.query,
+                  variables,
+                },
+              })
+            );
+          } catch (err) {
+            this.handleError(options.onError, err);
+          }
+        };
+ 
+        socket.onmessage = (event: MessageEvent) => {
+          if (unsubscribed || this.isDestroyed) return;
+          try {
+            const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
+            if (!data || typeof data !== 'object') return;
+ 
+            if (data.type === 'next' || data.type === 'data') {
+              const payload = data.payload ?? data.data;
+              options.onData(payload);
+            } else if (data.type === 'error') {
+              const errPayload = data.payload ?? data.errors;
+              const errMsg = typeof errPayload === 'string' ? errPayload : JSON.stringify(errPayload);
+              this.handleError(options.onError, new Error(errMsg));
+            }
+          } catch (err) {
+            this.handleError(options.onError, err);
+          }
+        };
+ 
+        socket.onerror = (_event: Event) => {
+          if (unsubscribed || this.isDestroyed) return;
+          this.handleError(options.onError, new Error(`GraphQL subscription WebSocket error on ${this.endpoint}`));
+        };
+ 
+        socket.onclose = () => {
+          if (!unsubscribed && !this.isDestroyed) {
+            subscription.unsubscribe();
+          }
+        };
+      } catch (err) {
+        this.handleError(options.onError, err);
+      }
+    } else {
+      const fetchFn = typeof fetch !== 'undefined' ? fetch : (globalThis as unknown as { fetch?: typeof fetch }).fetch;
+      Eif (typeof fetchFn === 'function' && typeof AbortController !== 'undefined') {
+        abortController = new AbortController();
+        const headers: Record<string, string> = {
+          'Content-Type': 'application/json',
+          'Accept': 'text/event-stream, application/json',
+          ...options.headers,
+        };
+ 
+        fetchFn(this.endpoint, {
+          method: 'POST',
+          headers,
+          body: JSON.stringify({ query: options.query, variables }),
+          signal: abortController.signal,
+        })
+          .then(async (response: Response) => {
+            Iif (unsubscribed || this.isDestroyed) return;
+            if (!response.ok) {
+              this.handleError(options.onError, new Error(`GraphQL subscription HTTP error: ${response.status}`));
+              return;
+            }
+            if (response.body && typeof response.body.getReader === 'function') {
+              const reader = response.body.getReader();
+              const decoder = new TextDecoder();
+              let buffer = '';
+              while (!unsubscribed && !this.isDestroyed) {
+                const { value, done } = await reader.read();
+                if (done) break;
+                buffer += decoder.decode(value, { stream: true });
+                const lines = buffer.split('\n');
+                buffer = lines.pop() ?? '';
+                for (const line of lines) {
+                  const trimmed = line.trim();
+                  if (trimmed.startsWith('data:')) {
+                    const dataStr = trimmed.slice(5).trim();
+                    if (dataStr === '[DONE]') break;
+                    try {
+                      const parsed = JSON.parse(dataStr);
+                      options.onData(parsed.data ?? parsed);
+                    } catch {
+                      // Ignore malformed line
+                    }
+                  }
+                }
+              }
+            }
+          })
+          .catch((err: unknown) => {
+            const isAbort = err instanceof Error && err.name === 'AbortError';
+            Eif (!unsubscribed && !this.isDestroyed && !isAbort) {
+              this.handleError(options.onError, err);
+            }
+          });
+      }
+    }
+ 
+    return subscription;
+  }
+ 
+  getSubscriptionCount(): number {
+    return this.activeSubscriptions.size;
+  }
+ 
+  cleanup(): void {
+    this.isDestroyed = true;
+    for (const sub of Array.from(this.activeSubscriptions)) {
+      sub.unsubscribe();
+    }
+    this.activeSubscriptions.clear();
+  }
+ 
+  private getWebSocketCtor(): (new (url: string | URL, protocols?: string | string[]) => WebSocket) | null {
+    if (typeof globalThis !== 'undefined' && 'WebSocket' in globalThis && typeof (globalThis as unknown as { WebSocket?: typeof WebSocket }).WebSocket === 'function') {
+      return (globalThis as unknown as { WebSocket?: typeof WebSocket }).WebSocket ?? null;
+    }
+    Iif (typeof WebSocket !== 'undefined' && typeof WebSocket === 'function') {
+      return WebSocket;
+    }
+    return null;
+  }
+ 
+  private getWsUrl(endpoint: string): string {
+    if (endpoint.startsWith('https://')) {
+      return 'wss://' + endpoint.slice(8);
+    }
+    if (endpoint.startsWith('http://')) {
+      return 'ws://' + endpoint.slice(7);
+    }
+    return endpoint;
+  }
+ 
+  private handleError(onError: ((err: Error) => void) | undefined, err: unknown): void {
+    if (onError && typeof onError === 'function') {
+      try {
+        onError(err instanceof Error ? err : new Error(String(err)));
+      } catch {
+        // Prevent uncaught errors from user error handlers
+      }
+    }
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/nonce-manager.ts.html b/coverage/lcov-report/src/nonce-manager.ts.html new file mode 100644 index 0000000..5e9299d --- /dev/null +++ b/coverage/lcov-report/src/nonce-manager.ts.html @@ -0,0 +1,220 @@ + + + + + + Code coverage report for src/nonce-manager.ts + + + + + + + + + +
+
+

All files / src nonce-manager.ts

+
+ +
+ 100% + Statements + 18/18 +
+ + +
+ 100% + Branches + 5/5 +
+ + +
+ 100% + Functions + 4/4 +
+ + +
+ 100% + Lines + 18/18 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46  +  +5x +  +  +5x +  +  +  +  +  +  +  +156x +  +  +156x +156x +  +  +156x +  +156x +156x +154x +  +156x +1x +1x +  +  +153x +153x +  +2x +2x +  +156x +  +  +  +  +1x +  +  + 
export class NonceManager {
+  private currentNonce: number;
+  private updateQueue: Promise<void> = Promise.resolve();
+ 
+  constructor(initialNonce: number = 0) {
+    this.currentNonce = initialNonce;
+  }
+ 
+  /**
+   * Safely fetches and manages the next nonce asynchronously.
+   * Uses a serialized update queue so concurrent callers cannot race.
+   */
+  async getNextNonce(networkFetcher: () => Promise<number>): Promise<number> {
+    const previousUpdate = this.updateQueue;
+    let release: (() => void) | undefined;
+ 
+    this.updateQueue = new Promise<void>((resolve) => {
+      release = resolve;
+    });
+ 
+    await previousUpdate;
+ 
+    try {
+      const rawResponse = await networkFetcher();
+      const networkNonce = Number.isFinite(rawResponse) ? Math.floor(rawResponse) : NaN;
+ 
+      if (Number.isNaN(networkNonce)) {
+        this.currentNonce += 1;
+        return this.currentNonce;
+      }
+ 
+      this.currentNonce = Math.max(this.currentNonce + 1, networkNonce);
+      return this.currentNonce;
+    } catch {
+      this.currentNonce += 1;
+      return this.currentNonce;
+    } finally {
+      release?.();
+    }
+  }
+ 
+  getCurrentNonce(): number {
+    return this.currentNonce;
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/nonce/NonceManager.ts.html b/coverage/lcov-report/src/nonce/NonceManager.ts.html new file mode 100644 index 0000000..a8fb75c --- /dev/null +++ b/coverage/lcov-report/src/nonce/NonceManager.ts.html @@ -0,0 +1,643 @@ + + + + + + Code coverage report for src/nonce/NonceManager.ts + + + + + + + + + +
+
+

All files / src/nonce NonceManager.ts

+
+ +
+ 93.1% + Statements + 81/87 +
+ + +
+ 82.97% + Branches + 39/47 +
+ + +
+ 95% + Functions + 19/20 +
+ + +
+ 94.04% + Lines + 79/84 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +27x +27x +27x +27x +  +  +27x +27x +  +27x +27x +  +27x +1x +  +26x +1x +  +25x +1x +  +  +  +  +55x +6x +6x +  +1x +  +  +49x +  +  +  +121x +  +  +  +124x +  +  +  +124x +122x +122x +  +  +2x +2x +  +  +  +  +124x +124x +3x +3x +  +  +121x +121x +121x +  +121x +125x +125x +  +  +121x +  +  +  +128x +128x +2x +2x +  +126x +  +  +  +  +5x +  +  +  +3x +  +  +  +5x +  +  +  +1x +  +  +  +1x +  +  +  +1x +1x +1x +1x +  +  +  +25x +25x +25x +25x +  +  +  +9x +3x +  +6x +6x +9x +  +1x +  +  +  +  +4x +  +  +  +4x +  +4x +4x +1x +  +  +  +4x +4x +  +3x +3x +  +  +  +  +  +  +  +  +  +  +1x +  +1x +2x +2x +  +2x +2x +1x +  +  +  +  +2x +  + 
export interface NonceLock {
+  nonce: bigint;
+  release: () => void;
+}
+ 
+export interface NonceManagerOptions {
+  startNonce?: bigint | number | string;
+  maxNonce?: bigint | number | string;
+}
+ 
+const MAX_SAFE_U64 = 18446744073709551615n;
+ 
+export class NonceManager {
+  private currentNonce: bigint;
+  private maxNonce: bigint;
+  private isLocked = false;
+  private lockQueue: Array<(value: NonceLock) => void> = [];
+  private acquiredNonces: Set<string> = new Set();
+  private isDestroyed = false;
+ 
+  constructor(options: NonceManagerOptions = {}) {
+    const rawStart = options.startNonce ?? 0n;
+    const rawMax = options.maxNonce ?? MAX_SAFE_U64;
+ 
+    this.currentNonce = this.toSafeBigInt(rawStart);
+    this.maxNonce = this.toSafeBigInt(rawMax);
+ 
+    if (this.currentNonce < 0n) {
+      throw new Error('NonceManager: startNonce cannot be negative');
+    }
+    if (this.maxNonce <= this.currentNonce) {
+      throw new Error('NonceManager: maxNonce must be greater than startNonce');
+    }
+    if (this.maxNonce > MAX_SAFE_U64) {
+      this.maxNonce = MAX_SAFE_U64;
+    }
+  }
+ 
+  private toSafeBigInt(value: bigint | number | string): bigint {
+    if (typeof value === 'string') {
+      try {
+        return BigInt(value);
+      } catch {
+        return 0n;
+      }
+    }
+    return BigInt(value);
+  }
+ 
+  private nonceKey(nonce: bigint): string {
+    return nonce.toString();
+  }
+ 
+  async acquire(): Promise<NonceLock> {
+    Iif (this.isDestroyed) {
+      throw new Error('NonceManager has been destroyed');
+    }
+ 
+    if (!this.isLocked) {
+      this.isLocked = true;
+      return this.nextNonce();
+    }
+ 
+    return new Promise<NonceLock>((resolve) => {
+      this.lockQueue.push(resolve);
+    });
+  }
+ 
+  private nextNonce(): NonceLock {
+    const nonce = this.currentNonce;
+    if (nonce >= this.maxNonce) {
+      this.releaseLock();
+      throw new Error(`NonceManager: nonce ${nonce} exceeds maximum ${this.maxNonce}`);
+    }
+ 
+    this.currentNonce = nonce + 1n;
+    const key = this.nonceKey(nonce);
+    this.acquiredNonces.add(key);
+ 
+    const release = () => {
+      this.acquiredNonces.delete(key);
+      this.releaseLock();
+    };
+ 
+    return { nonce, release };
+  }
+ 
+  private releaseLock(): void {
+    const next = this.lockQueue.shift();
+    if (next) {
+      const lock = this.nextNonce();
+      next(lock);
+    } else {
+      this.isLocked = false;
+    }
+  }
+ 
+  get current(): bigint {
+    return this.currentNonce;
+  }
+ 
+  get remaining(): bigint {
+    return this.maxNonce - this.currentNonce;
+  }
+ 
+  get acquired(): number {
+    return this.acquiredNonces.size;
+  }
+ 
+  async reset(nonce?: bigint | number): Promise<void> {
+    Iif (this.isDestroyed) {
+      throw new Error('NonceManager has been destroyed');
+    }
+ 
+    while (this.isLocked) {
+      await new Promise<void>(resolve => setTimeout(resolve, 0));
+    }
+ 
+    this.currentNonce = nonce !== undefined ? this.toSafeBigInt(nonce) : 0n;
+    this.acquiredNonces.clear();
+    this.lockQueue = [];
+    this.isLocked = false;
+  }
+ 
+  destroy(): void {
+    this.isDestroyed = true;
+    this.lockQueue = [];
+    this.isLocked = false;
+    this.acquiredNonces.clear();
+  }
+ 
+  static isNonceValid(value: unknown): value is bigint {
+    if (typeof value !== 'bigint' && typeof value !== 'number' && typeof value !== 'string') {
+      return false;
+    }
+    try {
+      const n = typeof value === 'bigint' ? value : BigInt(value);
+      return n >= 0n && n <= MAX_SAFE_U64;
+    } catch {
+      return false;
+    }
+  }
+ 
+  async acquireWithFallback(timeoutMs = 5000): Promise<NonceLock> {
+    Iif (this.isDestroyed) {
+      throw new Error('NonceManager has been destroyed');
+    }
+ 
+    const acquirePromise = this.acquire();
+ 
+    const timeoutPromise = new Promise<never>((_, reject) => {
+      setTimeout(() => {
+        reject(new Error(`NonceManager: acquire timed out after ${timeoutMs}ms`));
+      }, timeoutMs);
+    });
+ 
+    try {
+      return await Promise.race([acquirePromise, timeoutPromise]);
+    } catch (err) {
+      Eif (err instanceof Error) {
+        throw err;
+      }
+      throw new Error(String(err));
+    }
+  }
+ 
+  /** Safe acquisition with retry logic and exponential backoff. */
+  async safeAcquire(
+    retries = 3,
+    delayMs = 100,
+  ): Promise<NonceLock> {
+    let lastError: Error | null = null;
+ 
+    for (let attempt = 0; attempt < retries; attempt++) {
+      try {
+        return await this.acquireWithFallback();
+      } catch (err) {
+        lastError = err instanceof Error ? err : new Error(String(err));
+        if (attempt < retries - 1) {
+          await new Promise(r => setTimeout(r, delayMs * (attempt + 1)));
+        }
+      }
+    }
+ 
+    throw lastError ?? new Error('NonceManager: safeAcquire failed');
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/nonce/index.html b/coverage/lcov-report/src/nonce/index.html new file mode 100644 index 0000000..61df17c --- /dev/null +++ b/coverage/lcov-report/src/nonce/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for src/nonce + + + + + + + + + +
+
+

All files src/nonce

+
+ +
+ 93.1% + Statements + 81/87 +
+ + +
+ 82.97% + Branches + 39/47 +
+ + +
+ 95% + Functions + 19/20 +
+ + +
+ 94.04% + Lines + 79/84 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
NonceManager.ts +
+
93.1%81/8782.97%39/4795%19/2094.04%79/84
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/relayer/ErrorMapper.ts.html b/coverage/lcov-report/src/relayer/ErrorMapper.ts.html new file mode 100644 index 0000000..8a79515 --- /dev/null +++ b/coverage/lcov-report/src/relayer/ErrorMapper.ts.html @@ -0,0 +1,355 @@ + + + + + + Code coverage report for src/relayer/ErrorMapper.ts + + + + + + + + + +
+
+

All files / src/relayer ErrorMapper.ts

+
+ +
+ 92.59% + Statements + 25/27 +
+ + +
+ 86.66% + Branches + 13/15 +
+ + +
+ 100% + Functions + 6/6 +
+ + +
+ 100% + Lines + 24/24 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +7x +7x +  +  +7x +7x +7x +  +  +  +  +  +  +  +10x +1x +  +  +  +  +9x +  +9x +28x +28x +  +  +  +  +  +  +6x +2x +  +  +4x +4x +  +4x +  +  +  +4x +  +4x +  +  +  +  +12x +15x +  +12x +  +  +  +  +3x +3x +  +  + 
import type { WebSocketRelayer, WebSocketMessage, MessageHandler } from './WebSocketRelayer.js';
+import { ConduitError, type ConduitContract } from '../errors.js';
+ 
+export type MappedErrorHandler = (error: Error) => void;
+export type ErrorMessageTypeMap = Record<string, ConduitContract>;
+ 
+export interface ErrorMapperOptions {
+  errorMessageTypes?: ErrorMessageTypeMap;
+}
+ 
+/** WebSocket message types that carry a raw on-chain error payload, and which contract each belongs to. */
+const ERROR_MESSAGE_TYPES: Record<string, ConduitContract> = {
+  stream_error: 'stream',
+  factory_error: 'factory',
+  governor_error: 'governor',
+};
+ 
+/**
+ * Subscribes to error-shaped messages on a `WebSocketRelayer` and forwards
+ * them to `onError` as typed `ConduitError` instances instead of raw payloads.
+ *
+ * Must be paired with `dispose()` — see #81. Without it, each `attach()`
+ * leaves its listeners registered on the relayer forever, and any handler
+ * call still in flight when the caller stops caring can fire late.
+ */
+export class ErrorMapper {
+  private readonly relayer: WebSocketRelayer;
+  private readonly onError: MappedErrorHandler;
+  private readonly errorMessageTypes: ErrorMessageTypeMap;
+  private unsubscribers: Array<() => void> = [];
+  private isDisposed = false;
+ 
+  constructor(relayer: WebSocketRelayer, onError: MappedErrorHandler, options: ErrorMapperOptions = {}) {
+    this.relayer = relayer;
+    this.onError = onError;
+    this.errorMessageTypes = {
+      ...ERROR_MESSAGE_TYPES,
+      ...(options.errorMessageTypes ?? {}),
+    };
+  }
+ 
+  /** Registers one relayer listener per error message type. Safe to call more than once. */
+  attach(): void {
+    if (this.isDisposed) {
+      throw new Error('ErrorMapper has been disposed and cannot be re-attached');
+    }
+ 
+    // Re-attaching without detaching first would register a second set of
+    // listeners on top of the first, leaking the originals. Guard against it.
+    this.detach();
+ 
+    for (const type of Object.keys(this.errorMessageTypes)) {
+      const handler: MessageHandler = (msg) => this.handleMessage(type, msg);
+      this.unsubscribers.push(this.relayer.on(type, handler));
+    }
+  }
+ 
+  private handleMessage(type: string, msg: WebSocketMessage): void {
+    // Boundary check: bail before touching a disposed mapper or a
+    // null/malformed payload, rather than passing it downstream.
+    if (this.isDisposed || !msg || typeof msg !== 'object' || msg.payload == null) {
+      return;
+    }
+ 
+    const contract = this.errorMessageTypes[type];
+    Iif (!contract) return;
+ 
+    const mapped = ConduitError.fromContractError(contract, msg.payload);
+ 
+    // Re-check disposal after mapping: dispose() may have run while this
+    // callback was pending, and state shouldn't mutate after teardown.
+    Iif (this.isDisposed) return;
+ 
+    this.onError(mapped);
+  }
+ 
+  /** Unsubscribes every listener this mapper registered on the relayer. */
+  detach(): void {
+    for (const unsubscribe of this.unsubscribers) {
+      unsubscribe();
+    }
+    this.unsubscribers = [];
+  }
+ 
+  /** Detaches all listeners and permanently blocks any in-flight callback from firing. */
+  dispose(): void {
+    this.isDisposed = true;
+    this.detach();
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/relayer/WebSocketRelayer.ts.html b/coverage/lcov-report/src/relayer/WebSocketRelayer.ts.html new file mode 100644 index 0000000..765d7c6 --- /dev/null +++ b/coverage/lcov-report/src/relayer/WebSocketRelayer.ts.html @@ -0,0 +1,1165 @@ + + + + + + Code coverage report for src/relayer/WebSocketRelayer.ts + + + + + + + + + +
+
+

All files / src/relayer WebSocketRelayer.ts

+
+ +
+ 75.54% + Statements + 139/184 +
+ + +
+ 62.1% + Branches + 59/95 +
+ + +
+ 80% + Functions + 24/30 +
+ + +
+ 78.16% + Lines + 136/174 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +34x +34x +34x +34x +34x +34x +34x +34x +34x +  +  +34x +34x +  +  +34x +34x +34x +34x +  +  +  +  +  +98x +  +  +  +  +  +  +  +  +147x +29x +29x +  +118x +118x +  +  +  +  +147x +147x +118x +  +29x +  +  +  +  +95x +  +92x +92x +92x +3x +3x +  +  +  +  +  +  +  +47x +  +  +47x +47x +  +  +  +47x +47x +47x +19x +  +  +28x +28x +28x +  +47x +47x +  +  +  +  +28x +28x +  +  +  +  +  +  +  +  +28x +28x +  +  +  +  +28x +28x +  +28x +28x +  +  +  +  +28x +  +  +  +28x +  +28x +27x +27x +27x +27x +27x +27x +  +  +28x +1119x +  +  +28x +2x +2x +  +  +  +2x +2x +  +  +  +  +28x +1x +1x +1x +  +  +  +28x +  +  +  +  +  +  +  +1065x +1065x +1065x +  +  +  +  +  +1x +  +  +  +  +1119x +  +  +1117x +1117x +  +3x +  +  +1114x +  +1114x +1114x +  +1062x +1065x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +27x +27x +27x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +39x +36x +  +39x +  +39x +16x +16x +16x +16x +16x +  +  +  +  +  +  +1x +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +153x +  +  +  +153x +53x +53x +  +  +100x +100x +100x +100x +  +  +  +  +100x +100x +  +100x +  +  +  +  +2x +2x +2x +  +2x +2x +2x +  +  +  +2x +  +2x +  +  +  +36x +36x +36x +36x +36x +  +36x +16x +16x +16x +16x +16x +  +  +  +16x +  +  +36x +36x +36x +36x +36x +  +  + 
export interface WebSocketMessage {
+  type: string;
+  payload: unknown;
+  id?: string;
+}
+ 
+export type MessageHandler = (msg: WebSocketMessage) => Promise<void> | void;
+export type StateChangeHandler = (transition: RelayerStateTransition, state: RelayerState) => void;
+ 
+export interface RelayerState {
+  connected: boolean;
+  reconnecting: boolean;
+  destroyed: boolean;
+  pendingCount: number;
+}
+ 
+export type RelayerStateTransition = 'connecting' | 'connected' | 'disconnected' | 'reconnecting' | 'destroyed';
+ 
+export interface WebSocketRelayerOptions {
+  maxReconnectAttempts?: number;
+  reconnectDelayMs?: number;
+  onStateChange?: StateChangeHandler;
+}
+ 
+export class WebSocketRelayer {
+  private url: string;
+  private ws: WebSocket | null = null;
+  private handlers: Map<string, Set<MessageHandler>> = new Map();
+  private stateChangeHandlers: Set<StateChangeHandler> = new Set();
+  private isDestroyed = false;
+  private isLocked = false;
+  private lockQueue: Array<() => void> = [];
+  private connectPromise: Promise<void> | null = null;
+  private reconnectAttempts = 0;
+  private reconnectEnabled = true;
+  private maxReconnectAttempts: number;
+  private reconnectDelayMs: number;
+  private pendingMessages: WebSocketMessage[] = [];
+  private stateTransition: RelayerStateTransition = 'disconnected';
+ 
+  constructor(url: string, options?: WebSocketRelayerOptions) {
+    this.url = url;
+    this.maxReconnectAttempts = options?.maxReconnectAttempts ?? 5;
+    this.reconnectDelayMs = options?.reconnectDelayMs ?? 1000;
+    Iif (options?.onStateChange) {
+      this.stateChangeHandlers.add(options.onStateChange);
+    }
+  }
+ 
+  get state(): RelayerState {
+    return {
+      connected: this.ws !== null && this.ws.readyState === 1,
+      reconnecting: this.reconnectAttempts > 0,
+      destroyed: this.isDestroyed,
+      pendingCount: this.pendingMessages.length,
+    };
+  }
+ 
+  private async acquireLock(): Promise<void> {
+    if (!this.isLocked) {
+      this.isLocked = true;
+      return;
+    }
+    return new Promise((resolve) => {
+      this.lockQueue.push(resolve);
+    });
+  }
+ 
+  private releaseLock(): void {
+    const next = this.lockQueue.shift();
+    if (next) {
+      next();
+    } else {
+      this.isLocked = false;
+    }
+  }
+ 
+  private emitStateChange(transition: RelayerStateTransition): void {
+    if (this.stateTransition === transition) return;
+ 
+    this.stateTransition = transition;
+    const state = this.state;
+    for (const handler of this.stateChangeHandlers) {
+      try {
+        handler(transition, state);
+      } catch (err) {
+        console.warn('[WebSocketRelayer] state change handler error:', err);
+      }
+    }
+  }
+ 
+  async connect(): Promise<void> {
+    Iif (this.isDestroyed) {
+      throw new Error('WebSocketRelayer has been destroyed');
+    }
+    this.reconnectEnabled = true;
+    Iif (this.connectPromise) {
+      return this.connectPromise;
+    }
+ 
+    await this.acquireLock();
+    try {
+      if (this.ws && this.ws.readyState === 1) {
+        return;
+      }
+ 
+      this.emitStateChange('connecting');
+      this.connectPromise = this.establishConnection();
+      await this.connectPromise;
+    } finally {
+      this.releaseLock();
+      this.connectPromise = null;
+    }
+  }
+ 
+  private wsCtor(): typeof WebSocket | null {
+    Eif (typeof globalThis !== 'undefined' && 'WebSocket' in globalThis) {
+      return (globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket;
+    }
+    if (typeof WebSocket !== 'undefined') {
+      return WebSocket;
+    }
+    return null;
+  }
+ 
+  private async establishConnection(): Promise<void> {
+    const WebSocketCtor = this.wsCtor();
+    Iif (!WebSocketCtor) {
+      this.emitStateChange('disconnected');
+      return;
+    }
+ 
+    return new Promise((resolve, reject) => {
+      try {
+        let ws: WebSocket;
+        try {
+          ws = new WebSocketCtor(this.url);
+        } catch {
+          // Fallback for mocks that are plain functions.
+          ws = (WebSocketCtor as unknown as (url: string) => WebSocket)(this.url);
+        }
+        Iif (!ws) {
+          resolve();
+          return;
+        }
+        let settled = false;
+ 
+        ws.onopen = () => {
+          Iif (settled) return;
+          settled = true;
+          this.reconnectAttempts = 0;
+          this.flushPendingMessages();
+          this.emitStateChange('connected');
+          resolve();
+        };
+ 
+        ws.onmessage = (event: MessageEvent) => {
+          this.handleMessageSafe(event.data);
+        };
+ 
+        ws.onclose = () => {
+          this.ws = null;
+          Iif (!settled) {
+            settled = true;
+            reject(new Error(`WebSocket connection closed before opening: ${this.url}`));
+          }
+          this.emitStateChange('disconnected');
+          Iif (!this.isDestroyed && this.reconnectEnabled) {
+            this.attemptReconnect();
+          }
+        };
+ 
+        ws.onerror = () => {
+          Eif (!settled) {
+            settled = true;
+            reject(new Error(`WebSocket connection failed: ${this.url}`));
+          }
+        };
+ 
+        this.ws = ws;
+      } catch (err) {
+        reject(err);
+      }
+    });
+  }
+ 
+  private async safeInvokeHandler(handler: MessageHandler, msg: WebSocketMessage): Promise<void> {
+    try {
+      const result = handler(msg);
+      Iif (result && typeof (result as Promise<void>).catch === 'function') {
+        await (result as Promise<void>).catch((err: Error) => {
+          console.warn('[WebSocketRelayer] async handler error:', err);
+        });
+      }
+    } catch (err) {
+      console.warn('[WebSocketRelayer] handler error:', err);
+    }
+  }
+ 
+  private handleMessageSafe(data: string): void {
+    if (data === null || data === undefined) return;
+ 
+    let parsed: WebSocketMessage;
+    try {
+      parsed = JSON.parse(data) as WebSocketMessage;
+    } catch {
+      return;
+    }
+ 
+    Iif (!parsed || typeof parsed !== 'object' || !parsed.type || typeof parsed.type !== 'string') return;
+ 
+    const typeHandlers = this.handlers.get(parsed.type);
+    if (!typeHandlers) return;
+ 
+    for (const handler of typeHandlers) {
+      this.safeInvokeHandler(handler, parsed);
+    }
+  }
+ 
+ 
+  /** Idempotency guard: only reconnect if not already connected or reconnecting */
+  private shouldAttemptReconnect(): boolean {
+    return (
+      !this.isDestroyed &&
+      this.reconnectEnabled &&
+      this.reconnectAttempts < this.maxReconnectAttempts &&
+      (!this.ws || this.ws.readyState !== 1)
+    );
+  }
+ 
+  private flushPendingMessages(): void {
+    const pending = this.pendingMessages;
+    this.pendingMessages = [];
+    for (const msg of pending) {
+      this.send(msg).catch(() => {});
+    }
+  }
+ 
+  private async attemptReconnect(): Promise<void> {
+    if (!this.shouldAttemptReconnect()) return;
+ 
+    await this.acquireLock();
+    try {
+      if (!this.shouldAttemptReconnect()) return;
+ 
+      this.reconnectAttempts++;
+      this.emitStateChange('reconnecting');
+      await new Promise((r) => setTimeout(r, this.reconnectDelayMs * this.reconnectAttempts));
+      if (this.isDestroyed) return;
+ 
+      await this.establishConnection();
+    } catch {
+      // Reconnect attempt failed; the next scheduled attempt (if any) will retry.
+    } finally {
+      this.releaseLock();
+    }
+  }
+ 
+  on(type: string, handler: MessageHandler): () => void {
+    if (!this.handlers.has(type)) {
+      this.handlers.set(type, new Set());
+    }
+    this.handlers.get(type)!.add(handler);
+ 
+    return () => {
+      const handlers = this.handlers.get(type);
+      Eif (handlers) {
+        handlers.delete(handler);
+        Eif (handlers.size === 0) {
+          this.handlers.delete(type);
+        }
+      }
+    };
+  }
+ 
+  onStateChange(handler: StateChangeHandler): () => void {
+    this.stateChangeHandlers.add(handler);
+ 
+    return () => {
+      this.stateChangeHandlers.delete(handler);
+    };
+  }
+ 
+  off(type: string, handler: MessageHandler): void {
+    const handlers = this.handlers.get(type);
+    if (handlers) {
+      handlers.delete(handler);
+      if (handlers.size === 0) {
+        this.handlers.delete(type);
+      }
+    }
+  }
+ 
+  async send(message: WebSocketMessage): Promise<void> {
+    Iif (this.isDestroyed) {
+      throw new Error('WebSocketRelayer has been destroyed');
+    }
+ 
+    if (!this.ws || this.ws.readyState !== 1) {
+      this.pendingMessages.push(message);
+      return;
+    }
+ 
+    await this.acquireLock();
+    try {
+      Iif (this.isDestroyed) throw new Error('WebSocketRelayer has been destroyed');
+      Iif (!this.ws || this.ws.readyState !== 1) {
+        this.pendingMessages.push(message);
+        return;
+      }
+ 
+      const payload = JSON.stringify(message);
+      this.ws.send(payload);
+    } finally {
+      this.releaseLock();
+    }
+  }
+ 
+  disconnect(): void {
+    this.reconnectEnabled = false;
+    this.connectPromise = null;
+    this.reconnectAttempts = 0;
+ 
+    Eif (this.ws) {
+      try {
+        this.ws.close();
+      } catch {
+        // Socket already closed/closing; nothing to clean up.
+      }
+      this.ws = null;
+    }
+    this.emitStateChange('disconnected');
+  }
+ 
+  destroy(): void {
+    this.isDestroyed = true;
+    this.reconnectEnabled = false;
+    this.connectPromise = null;
+    this.pendingMessages = [];
+    this.reconnectAttempts = this.maxReconnectAttempts;
+ 
+    if (this.ws) {
+      try {
+        this.ws.onclose = null;
+        this.ws.onerror = null;
+        this.ws.onmessage = null;
+        this.ws.close();
+      } catch {
+        // Socket already closed/closing; nothing to clean up.
+      }
+      this.ws = null;
+    }
+ 
+    this.emitStateChange('destroyed');
+    this.handlers.clear();
+    this.stateChangeHandlers.clear();
+    this.lockQueue = [];
+    this.isLocked = false;
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/relayer/index.html b/coverage/lcov-report/src/relayer/index.html new file mode 100644 index 0000000..5baa0f5 --- /dev/null +++ b/coverage/lcov-report/src/relayer/index.html @@ -0,0 +1,131 @@ + + + + + + Code coverage report for src/relayer + + + + + + + + + +
+
+

All files src/relayer

+
+ +
+ 77.72% + Statements + 164/211 +
+ + +
+ 65.45% + Branches + 72/110 +
+ + +
+ 83.33% + Functions + 30/36 +
+ + +
+ 80.8% + Lines + 160/198 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
ErrorMapper.ts +
+
92.59%25/2786.66%13/15100%6/6100%24/24
WebSocketRelayer.ts +
+
75.54%139/18462.1%59/9580%24/3078.16%136/174
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/signer.ts.html b/coverage/lcov-report/src/signer.ts.html new file mode 100644 index 0000000..8c841f0 --- /dev/null +++ b/coverage/lcov-report/src/signer.ts.html @@ -0,0 +1,658 @@ + + + + + + Code coverage report for src/signer.ts + + + + + + + + + +
+
+

All files / src signer.ts

+
+ +
+ 97.72% + Statements + 86/88 +
+ + +
+ 87.77% + Branches + 79/90 +
+ + +
+ 100% + Functions + 18/18 +
+ + +
+ 100% + Lines + 83/83 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192  +  +  +  +  +  +  +  +  +2x +  +1x +  +  +1x +  +  +  +  +  +  +  +  +  +  +5x +5x +5x +5x +  +  +  +  +  +  +52x +52x +  +  +52x +52x +52x +52x +  +  +  +52x +21x +  +31x +  +  +  +52x +51x +  +1x +  +  +  +3x +  +  +  +  +  +  +11x +9x +5x +5x +5x +  +7x +2x +2x +2x +  +  +  +9x +2x +2x +2x +  +  +7x +  +  +  +  +  +  +  +8x +1x +  +7x +3x +  +4x +2x +  +  +2x +2x +  +  +  +  +2x +  +  +  +  +  +  +  +  +5x +1x +  +4x +3x +  +  +1x +1x +1x +  +  +  +32x +2x +  +30x +4x +  +  +26x +26x +  +26x +26x +26x +26x +26x +  +  +26x +2x +2x +2x +  +  +26x +  +26x +  +26x +25x +25x +22x +1x +  +  +21x +21x +  +  +2x +2x +  +  +  +  +  +2x +1x +  +1x +  +  +  +51x +51x +1x +  +51x +  +  + 
import { Keypair, Transaction } from '@stellar/stellar-sdk';
+import type { WalletAdapter } from './adapters/types.js';
+ 
+export interface Signer {
+  sign(tx: Transaction): void | Promise<void>;
+  publicKey(): string;
+}
+ 
+export class KeypairSigner implements Signer {
+  constructor(private readonly keypair: Keypair) {}
+  sign(tx: Transaction): void {
+    tx.sign(this.keypair);
+  }
+  publicKey(): string {
+    return this.keypair.publicKey();
+  }
+}
+ 
+export interface TransactionSignerOptions {
+  walletAdapter?: WalletAdapter;
+  rpcProvider?: { getChainId: () => Promise<number | string> };
+  timeoutMs?: number;
+  maxPayloadSize?: number;
+}
+ 
+const DEFAULT_TIMEOUT_MS = 5000;
+const MAX_TIMEOUT_MS = 300_000;
+const DEFAULT_MAX_PAYLOAD_SIZE = 1_048_576;
+const MAX_VALID_CHAIN_ID = 2147483647;
+ 
+export class TransactionSigner implements Signer {
+  private walletAdapter: WalletAdapter | undefined;
+  private rpcProvider: { getChainId: () => Promise<number | string> } | undefined;
+  private timeoutMs: number;
+  private maxPayloadSize: number;
+  private activeCallbacks: Set<() => void> = new Set();
+  private isDestroyed = false;
+ 
+  constructor(options: TransactionSignerOptions = {}) {
+    this.walletAdapter = options.walletAdapter;
+    this.rpcProvider = options.rpcProvider;
+    this.timeoutMs = TransactionSigner.normalizeTimeoutMs(options.timeoutMs);
+    this.maxPayloadSize = TransactionSigner.normalizeMaxPayloadSize(options.maxPayloadSize);
+  }
+ 
+  private static normalizeTimeoutMs(value: number | undefined): number {
+    if (value === undefined || isNaN(value) || value <= 0) {
+      return DEFAULT_TIMEOUT_MS;
+    }
+    return Math.min(value, MAX_TIMEOUT_MS);
+  }
+ 
+  private static normalizeMaxPayloadSize(value: number | undefined): number {
+    if (value === undefined || isNaN(value) || value <= 0 || value > DEFAULT_MAX_PAYLOAD_SIZE) {
+      return DEFAULT_MAX_PAYLOAD_SIZE;
+    }
+    return value;
+  }
+ 
+  isActive(): boolean {
+    return !this.isDestroyed;
+  }
+ 
+  /**
+   * Returns active chain ID extracted dynamically from wallet adapter or RPC provider.
+   */
+  async getChainId(): Promise<number> {
+    if (this.walletAdapter) {
+      if ('chainId' in this.walletAdapter && (this.walletAdapter as unknown as { chainId?: unknown }).chainId) {
+        const raw = (this.walletAdapter as unknown as { chainId: unknown }).chainId;
+        const parsed = typeof raw === 'number' ? raw : parseInt(String(raw).split(':').pop() || '1', 10);
+        if (!isNaN(parsed) && parsed > 0 && parsed <= MAX_VALID_CHAIN_ID) return parsed;
+      }
+      if ('getChainId' in this.walletAdapter && typeof (this.walletAdapter as unknown as { getChainId?: () => unknown }).getChainId === 'function') {
+        const raw = await (this.walletAdapter as unknown as { getChainId: () => Promise<unknown> }).getChainId();
+        const parsed = typeof raw === 'number' ? raw : parseInt(String(raw).split(':').pop() || '1', 10);
+        Iif (!isNaN(parsed) && parsed > 0 && parsed <= MAX_VALID_CHAIN_ID) return parsed;
+      }
+    }
+ 
+    if (this.rpcProvider && typeof this.rpcProvider.getChainId === 'function') {
+      const raw = await this.rpcProvider.getChainId();
+      const parsed = typeof raw === 'number' ? raw : parseInt(String(raw).split(':').pop() || '1', 10);
+      Eif (!isNaN(parsed) && parsed > 0) return parsed;
+    }
+ 
+    return 1;
+  }
+ 
+  async _signTypedData(
+    domain: Record<string, unknown>,
+    types: Record<string, unknown>,
+    value: Record<string, unknown>
+  ): Promise<Record<string, unknown>> {
+    if (this.isDestroyed) {
+      throw new Error('TransactionSigner has been destroyed');
+    }
+    if (domain === null || domain === undefined || typeof domain !== 'object') {
+      throw new Error('EIP-712 domain payload cannot be null or undefined');
+    }
+    if (value === null || value === undefined || typeof value !== 'object') {
+      throw new Error('Typed data value payload cannot be null or undefined');
+    }
+ 
+    const chainId = await this.getChainId();
+    const dynamicDomain = {
+      ...domain,
+      chainId,
+    };
+ 
+    return {
+      domain: dynamicDomain,
+      types,
+      value,
+      signature: '0x' + 'ab'.repeat(65),
+    };
+  }
+ 
+  async signProposal(streams: unknown[]): Promise<Record<string, unknown>> {
+    if (this.isDestroyed) {
+      throw new Error('TransactionSigner has been destroyed');
+    }
+    if (!Array.isArray(streams) || streams.length === 0) {
+      throw new Error('Proposal streams payload cannot be null, undefined, or empty');
+    }
+ 
+    const domain = { name: 'ConduitBatcher', version: '1' };
+    const types = { Proposal: [{ name: 'streams', type: 'string[]' }] };
+    return this._signTypedData(domain, types, { streams });
+  }
+ 
+  async sign(tx: Transaction): Promise<void> {
+    if (this.isDestroyed) {
+      throw new Error('TransactionSigner has been destroyed');
+    }
+    if (tx === null || tx === undefined) {
+      throw new Error('Transaction payload cannot be null or undefined');
+    }
+ 
+    return new Promise((resolve, reject) => {
+      let cleanedUp = false;
+ 
+      const cleanup = () => {
+        Iif (cleanedUp) return;
+        cleanedUp = true;
+        clearTimeout(timer);
+        this.activeCallbacks.delete(cleanup);
+      };
+ 
+      const timer = setTimeout(() => {
+        cleanup();
+        this.isDestroyed = true;
+        reject(new Error('TransactionSigner deadlocked or timed out waiting for async callback'));
+      }, this.timeoutMs);
+ 
+      this.activeCallbacks.add(cleanup);
+ 
+      Promise.resolve()
+        .then(async () => {
+          if (this.isDestroyed || cleanedUp) return;
+          Eif (this.walletAdapter) {
+            const res = await this.walletAdapter.signTransaction(tx);
+            if (res === null || res === undefined) {
+              throw new Error('Wallet adapter returned null or undefined transaction');
+            }
+          }
+          cleanup();
+          resolve();
+        })
+        .catch((err) => {
+          cleanup();
+          reject(err);
+        });
+    });
+  }
+ 
+  publicKey(): string {
+    if (this.isDestroyed) {
+      throw new Error('TransactionSigner has been destroyed');
+    }
+    return 'GTRANSACTIONSIGNERMOCKKEY';
+  }
+ 
+  cleanup(): void {
+    this.isDestroyed = true;
+    for (const callbackCleanup of this.activeCallbacks) {
+      callbackCleanup();
+    }
+    this.activeCallbacks.clear();
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/soroban.ts.html b/coverage/lcov-report/src/soroban.ts.html new file mode 100644 index 0000000..b2b5a3f --- /dev/null +++ b/coverage/lcov-report/src/soroban.ts.html @@ -0,0 +1,805 @@ + + + + + + Code coverage report for src/soroban.ts + + + + + + + + + +
+
+

All files / src soroban.ts

+
+ +
+ 100% + Statements + 65/65 +
+ + +
+ 100% + Branches + 30/30 +
+ + +
+ 100% + Functions + 13/13 +
+ + +
+ 100% + Lines + 63/63 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +18x +  +  +  +  +  +18x +  +  +  +  +  +  +  +  +  +  +  +18x +  +  +41x +41x +15x +15x +  +41x +  +  +  +  +  +  +12x +  +  +  +  +  +  +  +18x +18x +  +  +9x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +11x +  +  +11x +11x +  +2x +  +  +9x +  +9x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +9x +9x +  +  +  +9x +9x +  +1x +  +8x +1x +  +  +  +7x +  +  +7x +  +  +  +7x +7x +  +1x +  +6x +1x +  +  +  +5x +5x +7x +  +7x +7x +  +1x +  +6x +1x +  +5x +1x +  +  +2x +  +  +  +  +  +  +  +  +  +  +6x +  +  +6x +6x +  +2x +  +  +4x +1x +  +3x +1x +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +3x +1x +1x +  +  +  +  +20x +20x +20x +  +20x +  +  +  +  +12x +  +  +  +  +5x +  +  +  +  +9x +  +  +  +7x +  + 
/**
+ * Low-level Soroban RPC helpers.
+ *
+ * Wraps @stellar/stellar-sdk's SorobanRpc to provide a thin
+ * simulate → assemble → sign → submit pipeline.
+ *
+ * Performance note: SorobanRpc.Server instances are cached per RPC URL
+ * so that repeated calls (e.g. simulate → submit → poll) reuse the
+ * same HTTP client, avoiding redundant TLS and connection-pool setup.
+ */
+ 
+import {
+  SorobanRpc,
+  TransactionBuilder,
+  Networks,
+  Contract,
+  xdr,
+  BASE_FEE,
+} from '@stellar/stellar-sdk';
+import type { Network } from './types/index.js';
+import type { Signer } from './signer.js';
+import { RateLimitError } from './errors.js';
+ 
+export const DEFAULT_RPC: Record<Network, string> = {
+  mainnet: 'https://soroban-mainnet.stellar.org',
+  testnet: 'https://soroban-testnet.stellar.org',
+  local:   'http://localhost:8000/soroban/rpc',
+};
+ 
+export const NETWORK_PASSPHRASE: Record<Network, string> = {
+  mainnet: Networks.PUBLIC,
+  testnet: Networks.TESTNET,
+  local:   Networks.STANDALONE,
+};
+ 
+/**
+ * Cache SorobanRpc.Server instances per RPC URL so that callers
+ * who invoke multiple RPC methods sequentially or in parallel do
+ * not create a new HTTP client for every single call.
+ * Keyed by rpcUrl so different network targets get independent caches.
+ */
+const _serverCache = new Map<string, SorobanRpc.Server>();
+ 
+export function getServer(rpcUrl: string): SorobanRpc.Server {
+  let srv = _serverCache.get(rpcUrl);
+  if (!srv) {
+    srv = new SorobanRpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith('http://') });
+    _serverCache.set(rpcUrl, srv);
+  }
+  return srv;
+}
+ 
+/**
+ * Clear the server cache. Intended for use in tests.
+ */
+export function clearServerCache(): void {
+  _serverCache.clear();
+}
+ 
+export interface ConfirmationPollingOptions {
+  pollIntervalMs?: number;
+  maxAttempts?: number;
+}
+ 
+export const DEFAULT_CONFIRMATION_POLL_INTERVAL_MS = 1000;
+export const DEFAULT_CONFIRMATION_MAX_ATTEMPTS = 30;
+ 
+function normalizePollingOptions(options: ConfirmationPollingOptions = {}): Required<ConfirmationPollingOptions> {
+  return {
+    pollIntervalMs: options.pollIntervalMs ?? DEFAULT_CONFIRMATION_POLL_INTERVAL_MS,
+    maxAttempts: options.maxAttempts ?? DEFAULT_CONFIRMATION_MAX_ATTEMPTS,
+  };
+}
+ 
+/**
+ * Build a contract-call transaction for simulate or submit.
+ *
+ * Fetches the caller's account from the RPC to get the current sequence
+ * number, then wraps the call in a TransactionBuilder.
+ */
+export async function buildContractCallTx(
+  rpcUrl:      string,
+  passphrase:  string,
+  caller:      string,
+  contractId:  string,
+  method:      string,
+  args:        xdr.ScVal[],
+): Promise<ReturnType<TransactionBuilder['build']>> {
+  const server  = getServer(rpcUrl);
+ 
+  let account;
+  try {
+    account = await server.getAccount(caller);
+  } catch (err) {
+    throw RateLimitError.fromRpcError(err) ?? err;
+  }
+ 
+  const contract = new Contract(contractId);
+ 
+  return new TransactionBuilder(account, {
+    fee:            BASE_FEE,
+    networkPassphrase: passphrase,
+  })
+    .addOperation(contract.call(method, ...args))
+    .setTimeout(30)
+    .build();
+}
+ 
+/**
+ * Simulate a transaction, then assemble + sign + submit.
+ * Returns the transaction hash on success.
+ */
+export async function invokeContract(
+  rpcUrl:     string,
+  passphrase: string,
+  signer:     Signer,
+  tx:         ReturnType<TransactionBuilder['build']>,
+  pollingOptions: ConfirmationPollingOptions = {},
+): Promise<string> {
+  const server = getServer(rpcUrl);
+  const polling = normalizePollingOptions(pollingOptions);
+ 
+  // Simulate
+  let simResult;
+  try {
+    simResult = await server.simulateTransaction(tx);
+  } catch (err) {
+    throw RateLimitError.fromRpcError(err) ?? err;
+  }
+  if (SorobanRpc.Api.isSimulationError(simResult)) {
+    throw new Error(`Simulation failed: ${simResult.error}`);
+  }
+ 
+  // Assemble (adds soroban auth + footprint)
+  const assembled = SorobanRpc.assembleTransaction(tx, simResult).build();
+ 
+  // Sign
+  await signer.sign(assembled);
+ 
+  // Submit
+  let sent;
+  try {
+    sent = await server.sendTransaction(assembled);
+  } catch (err) {
+    throw RateLimitError.fromRpcError(err) ?? err;
+  }
+  if (sent.status === 'ERROR') {
+    throw new Error(`Transaction rejected: ${JSON.stringify(sent.errorResult)}`);
+  }
+ 
+  // Poll for confirmation
+  const hash = sent.hash;
+  for (let i = 0; i < polling.maxAttempts; i++) {
+    await sleep(polling.pollIntervalMs);
+    let status;
+    try {
+      status = await server.getTransaction(hash);
+    } catch (err) {
+      throw RateLimitError.fromRpcError(err) ?? err;
+    }
+    if (status.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) {
+      return hash;
+    }
+    if (status.status === SorobanRpc.Api.GetTransactionStatus.FAILED) {
+      throw new Error(`Transaction failed: ${hash}`);
+    }
+  }
+  throw new Error(`Transaction timed out: ${hash}`);
+}
+ 
+/**
+ * Simulate a read-only call and return the result XDR.
+ */
+export async function simulateReadOnly(
+  rpcUrl:     string,
+  passphrase: string,
+  tx:         ReturnType<TransactionBuilder['build']>,
+): Promise<xdr.ScVal> {
+  const server = getServer(rpcUrl);
+ 
+  let result;
+  try {
+    result = await server.simulateTransaction(tx);
+  } catch (err) {
+    throw RateLimitError.fromRpcError(err) ?? err;
+  }
+ 
+  if (SorobanRpc.Api.isSimulationError(result)) {
+    throw new Error(`Simulation error: ${result.error}`);
+  }
+  if (!result.result) {
+    throw new Error('Simulation returned no result');
+  }
+  return xdr.ScVal.fromXDR(result.result.retval.toXDR());
+}
+ 
+/**
+ * Query a token contract's `decimals()` — part of the standard Stellar
+ * Asset / SEP-41 token interface every `CreateStreamParams.token` must
+ * implement. Callers must not assume 7 decimals (the native XLM/Stellar
+ * Asset Contract default) for arbitrary token addresses.
+ */
+export async function getTokenDecimals(
+  rpcUrl:     string,
+  passphrase: string,
+  callerAddr: string,
+  tokenId:    string,
+): Promise<number> {
+  const tx  = await buildContractCallTx(rpcUrl, passphrase, callerAddr, tokenId, 'decimals', []);
+  const val = await simulateReadOnly(rpcUrl, passphrase, tx);
+  return val.u32();
+}
+ 
+/** Convert an ScVal i128 to bigint */
+export function scValToI128(val: xdr.ScVal): bigint {
+  const i128 = val.i128();
+  const hi   = BigInt(i128.hi().toString());
+  const lo   = BigInt(i128.lo().toString());
+  // hi is signed high 64 bits, lo is unsigned low 64 bits
+  return (hi << 64n) | lo;
+}
+ 
+/** Convert an ScVal u64 to bigint */
+export function scValToU64(val: xdr.ScVal): bigint {
+  return BigInt(val.u64().toString());
+}
+ 
+/** Encode a u64 value as ScVal */
+export function u64ToScVal(val: bigint | number): xdr.ScVal {
+  return xdr.ScVal.scvU64(xdr.Uint64.fromString(val.toString()));
+}
+ 
+/** Encode a boolean as ScVal */
+export function boolToScVal(val: boolean): xdr.ScVal {
+  return xdr.ScVal.scvBool(val);
+}
+ 
+function sleep(ms: number): Promise<void> {
+  return new Promise(r => setTimeout(r, ms));
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/streams.ts.html b/coverage/lcov-report/src/streams.ts.html new file mode 100644 index 0000000..232a337 --- /dev/null +++ b/coverage/lcov-report/src/streams.ts.html @@ -0,0 +1,1807 @@ + + + + + + Code coverage report for src/streams.ts + + + + + + + + + +
+
+

All files / src streams.ts

+
+ +
+ 95.86% + Statements + 232/242 +
+ + +
+ 85% + Branches + 153/180 +
+ + +
+ 97.43% + Functions + 38/39 +
+ + +
+ 96.44% + Lines + 217/225 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +22x +  +  +  +  +  +  +  +  +  +  +  +  +17x +  +  +17x +16x +4x +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +102x +102x +102x +102x +102x +  +102x +8x +94x +46x +  +  +  +  +  +  +  +4x +  +  +  +1x +  +  +  +105x +2x +2x +  +103x +95x +46x +  +  +  +  +  +  +  +  +40x +10x +10x +  +31x +30x +29x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +17x +17x +  +  +  +  +16x +  +  +16x +1x +  +15x +1x +  +14x +1x +  +13x +1x +  +12x +1x +  +11x +2x +  +  +9x +  +  +17x +  +7x +7x +  +  +17x +17x +  +17x +  +  +  +  +  +  +  +  +  +  +17x +7x +7x +  +7x +1x +  +  +6x +6x +6x +  +3x +1x +  +2x +  +2x +17x +  +  +  +  +34x +34x +33x +33x +33x +32x +  +  +  +  +1x +1x +1x +1x +1x +1x +  +  +  +  +2x +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +6x +  +6x +10x +  +  +5x +10x +10x +8x +  +2x +2x +  +  +  +  +  +  +  +  +  +2x +2x +  +  +  +  +4x +4x +  +  +  +  +2x +2x +  +  +  +  +2x +2x +  +  +  +  +  +  +  +  +  +3x +3x +2x +2x +2x +2x +  +2x +1x +  +  +1x +1x +1x +  +1x +  +  +1x +  +  +  +  +  +  +  +  +17x +17x +  +  +  +  +  +17x +  +5x +5x +  +  +  +5x +3x +  +2x +  +  +14x +2x +  +14x +  +14x +13x +32x +13x +13x +13x +  +  +  +  +  +  +  +  +  +  +  +14x +10x +4x +3x +  +  +  +1x +  +  +  +  +  +  +  +6x +6x +4x +4x +  +  +  +  +5x +5x +  +5x +4x +  +1x +1x +1x +  +  +5x +  +5x +5x +1x +1x +  +  +5x +  +  +  +  +  +  +  +24x +8x +  +  +  +  +28x +25x +  +3x +1x +  +2x +1x +  +1x +  +  +  +20x +16x +  +  +16x +2x +  +14x +  +  +14x +  +4x +3x +3x +1x +  +3x +  +1x +1x +1x +  +  +  +  +  +50x +25x +  +  +  +50x +  +  +  +44x +44x +43x +  +  +  +34x +34x +34x +1x +  +33x +33x +  +  +  +  +7x +7x +7x +7x +7x +  +  +7x +7x +7x +5x +  +  +  +  +  +  +  +18x +18x +  +1x +  +17x +2x +  +15x +15x +18x +18x +19x +  +19x +19x +  +1x +  +18x +10x +  +8x +2x +  +  +2x +  +  +  +  +  +  +32x +32x +32x +  +  +  +32x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +32x +32x +  +  +  +19x +  + 
/**
+ * StreamsModule - all DripStream + DripFactory operations.
+ */
+ 
+import { SorobanRpc, nativeToScVal, xdr, Address, Transaction } from '@stellar/stellar-sdk';
+import type { Signer } from './signer.js';
+import type {
+  ConduitConfig,
+  CreateStreamParams,
+  CreateStreamResult,
+  ListStreamsParams,
+  PaginatedStreams,
+  StreamEventHandlers,
+  StreamInfo,
+  Subscription,
+  BatchWithdrawItem,
+  BatchWithdrawResult,
+} from './types/index.js';
+import type { WalletAdapter } from './adapters/types.js';
+import { KeypairWalletAdapter } from './adapters/keypair.js';
+import { toStroops, calculateRate, bigintSafeStringify } from './utils.js';
+import {
+  buildContractCallTx,
+  scValToI128,
+  scValToU64,
+  boolToScVal,
+  getTokenDecimals,
+  DEFAULT_RPC,
+  NETWORK_PASSPHRASE,
+  DEFAULT_CONFIRMATION_MAX_ATTEMPTS,
+  DEFAULT_CONFIRMATION_POLL_INTERVAL_MS,
+} from './soroban.js';
+import { FactoryModule } from './factory.js';
+import { ConduitError, RateLimitError, StreamErrorCode } from './errors.js';
+ 
+// Deprecation warnings
+ 
+/**
+ * Tracks which v1-deprecated methods have already warned this session, so
+ * repeated calls (e.g. in a hot loop) do not spam the console.
+ */
+const _warnedDeprecations = new Set<string>();
+ 
+/**
+ * Logs a one-time console warning for a deprecated v1 method, but only in
+ * development mode. Safe to call in browser bundles: guards `process` with
+ * a `typeof` check since it is not guaranteed to exist outside Node/bundlers
+ * that define it at build time.
+ *
+ * @param methodName - The deprecated method, e.g. 'StreamsModule.create()'.
+ * @param replacement - The suggested replacement, e.g. 'StreamBuilder'.
+ */
+function warnV1Deprecated(methodName: string, replacement: string): void {
+  const isDev =
+    typeof process !== 'undefined' &&
+    typeof process.env !== 'undefined' &&
+    process.env.NODE_ENV !== 'production';
+  if (!isDev) return;
+  if (_warnedDeprecations.has(methodName)) return;
+  _warnedDeprecations.add(methodName);
+  console.warn(
+    `[conduit-sdk] ${methodName} is deprecated and will be removed in a future ` +
+    `major version. Use ${replacement} instead.`,
+  );
+}
+import { ZERO_ADDR } from './constants.js';
+ 
+export class StreamsModule {
+  private readonly rpcUrl:     string;
+  private readonly passphrase: string;
+  private readonly callerAddr: string;
+  private readonly _factory:   FactoryModule;
+  private _cachedServer?:     SorobanRpc.Server;
+  private activeWallet?:       WalletAdapter;
+ 
+  constructor(private readonly config: ConduitConfig) {
+    this.rpcUrl     = config.rpcUrl ?? DEFAULT_RPC[config.network];
+    this.passphrase = NETWORK_PASSPHRASE[config.network];
+    this.callerAddr = this._signerPublicKey();
+    this._factory   = new FactoryModule(config);
+ 
+    if (config.wallet) {
+      this.activeWallet = config.wallet;
+    } else if (config.keypair) {
+      this.activeWallet = new KeypairWalletAdapter(config.keypair);
+    }
+  }
+ 
+  /**
+   * Dynamically set or update the active wallet adapter.
+   */
+  setWallet(wallet: WalletAdapter): void {
+    this.activeWallet = wallet;
+  }
+ 
+  private _signer(): Signer | null {
+    return this.config.signer ?? null;
+  }
+ 
+  private _signerPublicKey(): string {
+    if (this.activeWallet) {
+      const pk = this.activeWallet.getPublicKey();
+      Eif (typeof pk === 'string') return pk;
+    }
+    if (this.config.signer) return this.config.signer.publicKey();
+    if (this.config.keypair) return this.config.keypair.publicKey();
+    return ZERO_ADDR;
+  }
+ 
+  /**
+   * Resolve the caller address, handling both sync and async getPublicKey().
+   * Unlike _signerPublicKey(), this can be used when the wallet adapter
+   * returns a promise - but it MUST only be called from async contexts.
+   */
+  private async _resolveCallerAddress(): Promise<string> {
+    if (this.activeWallet) {
+      const pk = await this.activeWallet.getPublicKey();
+      if (pk) return pk;
+    }
+    if (this.config.signer) return this.config.signer.publicKey();
+    if (this.config.keypair) return this.config.keypair.publicKey();
+    return ZERO_ADDR;
+  }
+ 
+  /**
+  /**
+   * Deploy a new DripStream via DripFactory.
+   *
+   * Simulates first to extract the assigned stream ID from the return value,
+   * then signs and submits the assembled transaction.
+   *
+   * @deprecated Use {@link StreamBuilder} instead (see `builder.ts`), which
+   * provides a fluent `.token().sender().recipient().amount().ratePerSecond()`
+   * config API plus `.build()` / `.submit()` with built-in concurrency and
+   * backpressure handling. This method will be removed in a future major
+   * version. In development mode (`NODE_ENV !== 'production'`) this method
+   * logs a one-time console warning when invoked.
+   */
+  async create(params: CreateStreamParams): Promise<CreateStreamResult> {
+    warnV1Deprecated('StreamsModule.create()', 'StreamBuilder');
+    const senderAddr = await this._getSenderAddress();
+    const {
+      recipient, token, depositAmount,
+      durationSeconds, ratePerSecond,
+      startTime, clawbackEnabled = false,
+    } = params;
+ 
+    // Client-side validation to prevent invalid payloads
+    if (!recipient || typeof recipient !== 'string' || !recipient.trim()) {
+      throw new Error('Invalid recipient address: must be a non-empty string');
+    }
+    if (!token || typeof token !== 'string' || !token.trim()) {
+      throw new Error('Invalid token address: must be a non-empty string');
+    }
+    if (!depositAmount || typeof depositAmount !== 'string' || !depositAmount.trim()) {
+      throw new Error('Invalid deposit amount: must be a non-empty string');
+    }
+    if (durationSeconds !== undefined && (typeof durationSeconds !== 'number' || durationSeconds <= 0)) {
+      throw new Error('Invalid durationSeconds: must be a positive number');
+    }
+    if (ratePerSecond !== undefined && (typeof ratePerSecond !== 'string' || !ratePerSecond.trim())) {
+      throw new Error('Invalid ratePerSecond: must be a non-empty string');
+    }
+    if (!durationSeconds && !ratePerSecond) {
+      throw new Error('Either durationSeconds or ratePerSecond must be provided');
+    }
+ 
+    const factoryId = this.config.factoryAddress ?? '';
+ 
+    // Query token decimals
+    const decimals = await getTokenDecimals(this.rpcUrl, this.passphrase, senderAddr, token);
+ 
+    const depositStroops = toStroops(depositAmount, decimals);
+    const rateStroops    = ratePerSecond
+      ? BigInt(ratePerSecond)
+      : calculateRate(depositAmount, durationSeconds!, decimals);
+    const start = startTime ?? Math.floor(Date.now() / 1000);
+    const end   = durationSeconds ? start + durationSeconds : 0;
+ 
+    const args = [
+      new Address(senderAddr).toScVal(),
+      new Address(recipient).toScVal(),
+      new Address(token).toScVal(),
+      nativeToScVal(depositStroops, { type: 'i128' }),
+      nativeToScVal(rateStroops,    { type: 'i128' }),
+      nativeToScVal(start,          { type: 'u64'  }),
+      nativeToScVal(end,            { type: 'u64'  }),
+      boolToScVal(clawbackEnabled),
+    ];
+ 
+    const tx     = await buildContractCallTx(this.rpcUrl, this.passphrase, senderAddr, factoryId, 'create_stream', args);
+    const server = this._server();
+    const sim    = await server.simulateTransaction(tx);
+ 
+    if (SorobanRpc.Api.isSimulationError(sim)) {
+      throw ConduitError.fromSorobanMessage('factory', sim.error);
+    }
+ 
+    const assembled = SorobanRpc.assembleTransaction(tx, sim).build();
+    const signed    = await this._signTx(assembled);
+    const { hash: txHash, returnValue } = await this._sendAndPoll(server, signed);
+ 
+    if (!returnValue) {
+      throw new Error(`Transaction ${txHash} succeeded but returned no value`);
+    }
+    const streamId = scValToU64(returnValue);
+ 
+    const streamAddress = await this._factory.streamAddress(streamId) ?? '';
+    return { streamId, streamAddress, txHash };
+  }
+ 
+  /** Fetch full stream state from the deployed DripStream contract. */
+  async get(streamId: bigint | string): Promise<StreamInfo> {
+    const id   = BigInt(streamId);
+    const addr = await this._resolveAddr(id);
+    const caller = await this._resolveCallerAddress();
+    const tx   = await buildContractCallTx(this.rpcUrl, this.passphrase, caller, addr, 'info', []);
+    const val  = await this._simulateTx(tx);
+    return parseStreamInfo(id, addr, val);
+  }
+ 
+  /** Get withdrawable balance - read-only, no transaction. */
+  async withdrawable(streamId: bigint | string): Promise<bigint> {
+    const id   = BigInt(streamId);
+    const addr = await this._resolveAddr(id);
+    const caller = await this._resolveCallerAddress();
+    const tx   = await buildContractCallTx(this.rpcUrl, this.passphrase, caller, addr, 'withdrawable', []);
+    const val  = await this._simulateTx(tx);
+    return scValToI128(val);
+  }
+ 
+  /** Withdraw tokens as the recipient. Defaults to full available balance. */
+  async withdraw(streamId: bigint | string, amount?: bigint): Promise<string> {
+    this._ensureCanMutate();
+    const id  = BigInt(streamId);
+    const qty = amount ?? await this.withdrawable(id);
+    return this._invoke(await this._resolveAddr(id), 'withdraw', [
+      nativeToScVal(qty, { type: 'i128' }),
+    ]);
+  }
+ 
+  /**
+   * Withdraw from multiple streams concurrently.
+   *
+   * Note: Soroban currently permits only one invoke_host_function
+   * operation per transaction, so this cannot be assembled into a single
+   * atomic transaction the way classic Stellar payment operations can.
+   * Each withdrawal is submitted as its own transaction; they run
+   * concurrently and are reported independently so a failure on one
+   * streamId (e.g. StreamNotFound, insufficient balance) does not block
+   * or roll back the others.
+   */
+  async batchWithdraw(withdrawals: BatchWithdrawItem[]): Promise<BatchWithdrawResult[]> {
+    this._ensureCanMutate();
+ 
+    const settled = await Promise.allSettled(
+      withdrawals.map(w => this.withdraw(w.streamId, w.amount)),
+    );
+ 
+    return settled.map((result, i) => {
+      const streamId = BigInt(withdrawals[i]!.streamId);
+      if (result.status === 'fulfilled') {
+        return { streamId, success: true, txHash: result.value };
+      }
+      const err = result.reason;
+      return {
+        streamId,
+        success: false,
+        error: err instanceof Error ? err.message : String(err),
+      };
+    });
+  }
+ 
+  /** Cancel the stream (sender only). Settles all balances atomically. */
+  async cancel(streamId: bigint | string): Promise<string> {
+    this._ensureCanMutate();
+    return this._invoke(await this._resolveAddr(BigInt(streamId)), 'cancel', []);
+  }
+ 
+  /** Pause the stream (sender only). */
+  async pause(streamId: bigint | string): Promise<string> {
+    this._ensureCanMutate();
+    return this._invoke(await this._resolveAddr(BigInt(streamId)), 'pause', []);
+  }
+ 
+  /** Resume a paused stream (sender only). Shifts start/end times forward. */
+  async resume(streamId: bigint | string): Promise<string> {
+    this._ensureCanMutate();
+    return this._invoke(await this._resolveAddr(BigInt(streamId)), 'resume', []);
+  }
+ 
+  /** Deposit additional tokens into the stream (sender only). */
+  async topUp(streamId: bigint | string, amount: bigint): Promise<string> {
+    this._ensureCanMutate();
+    return this._invoke(await this._resolveAddr(BigInt(streamId)), 'top_up', [
+      nativeToScVal(amount, { type: 'i128' }),
+    ]);
+  }
+ 
+  /**
+   * Clawback unstreamed tokens (sender; only if enabled at creation).
+   * Returns the amount reclaimed (simulated before submission).
+   */
+  async clawback(streamId: bigint | string): Promise<bigint> {
+    this._ensureCanMutate();
+    const addr   = await this._resolveAddr(BigInt(streamId));
+    const caller = await this._getSenderAddress();
+    const tx     = await buildContractCallTx(this.rpcUrl, this.passphrase, caller, addr, 'clawback', []);
+    const server = this._server();
+    const sim    = await server.simulateTransaction(tx);
+ 
+    if (SorobanRpc.Api.isSimulationError(sim)) {
+      throw ConduitError.fromSorobanMessage('stream', sim.error);
+    }
+ 
+    const assembled = SorobanRpc.assembleTransaction(tx, sim).build();
+    const signed    = await this._signTx(assembled);
+    const { hash, returnValue } = await this._sendAndPoll(server, signed);
+ 
+    Iif (!returnValue) {
+      throw new Error(`Transaction ${hash} succeeded but returned no value`);
+    }
+    return scValToI128(returnValue);
+  }
+ 
+  /**
+   * List streams by sender or recipient with pagination metadata.
+   * Returns a page of StreamInfo along with pagination metadata so the
+   * frontend can implement infinite scrolling.
+   */
+  async list(params: ListStreamsParams): Promise<PaginatedStreams> {
+    const { sender, recipient, limit = 20 } = params;
+    let offset = params.offset ?? 0;
+ 
+    // A cursor from a previous page's nextCursor takes precedence over a
+    // manually-supplied offset. Cursors are opaque base64-encoded offset
+    // strings; anything that doesn't decode to a non-negative integer is
+    // rejected rather than silently falling back to page 1 (see #124).
+    if (params.cursor !== undefined) {
+      let decoded: string;
+      try {
+        decoded = Buffer.from(params.cursor, 'base64').toString('utf8');
+      } catch {
+        throw new Error(`Invalid cursor: "${params.cursor}"`);
+      }
+      if (!/^\d+$/.test(decoded)) {
+        throw new Error(`Invalid cursor: "${params.cursor}"`);
+      }
+      offset = Number(decoded);
+    }
+ 
+    const encodeCursor = (nextOffset: number): string =>
+      Buffer.from(String(nextOffset), 'utf8').toString('base64');
+ 
+    let ids: bigint[] = [];
+ 
+    const pageFromFilteredIds = async (filteredIds: bigint[]): Promise<PaginatedStreams> => {
+      ids = filteredIds;
+      const streams = await Promise.all(ids.map(id => this.get(id)));
+      const hasNextPage = ids.length === limit;
+      const totalCount = BigInt(offset + ids.length);
+      return {
+        streams,
+        hasNextPage,
+        totalCount,
+        offset,
+        limit,
+        ...(hasNextPage ? { nextCursor: encodeCursor(offset + limit) } : {}),
+      };
+    };
+ 
+    // Sender/recipient contract queries already return the filtered page.
+    // There is no scoped count method, so do not mix in global stream_count().
+    if (sender) {
+      return pageFromFilteredIds(await this._factory.streamsBySender(sender, offset, limit));
+    } else if (recipient) {
+      return pageFromFilteredIds(await this._factory.streamsByRecipient(recipient, offset, limit));
+    }
+ 
+   // Neither sender nor recipient - return empty page
+    return { streams: [], hasNextPage: false, totalCount: 0n, offset, limit };
+  }
+ 
+  /** Subscribe to on-chain stream events via polling. Returns an async subscription handle. */
+  async subscribeAsync(
+    streamId: bigint | string,
+    handlers: StreamEventHandlers,
+  ): Promise<Subscription> {
+    const address = await this._factory.streamAddress(BigInt(streamId));
+    if (!address) throw new Error(`Stream ${streamId} not found`);
+    const { subscribeToStream } = await import('./events.js');
+    return subscribeToStream(this.config.rpcUrl!, address, handlers);
+  }
+ 
+  /** Synchronous subscribe - resolves address lazily on first poll tick. */
+  subscribe(streamId: bigint | string, handlers: StreamEventHandlers): Subscription {
+    let inner: Subscription | null = null;
+    let stopped = false;
+ 
+    this.subscribeAsync(streamId, handlers)
+      .then(sub => { if (!stopped) inner = sub; else sub.unsubscribe(); })
+      .catch(err => {
+        const error = err instanceof Error ? err : new Error(String(err));
+        handlers.onError?.(error);
+        console.warn('[conduit-sdk] subscribe error:', error);
+      });
+ 
+    return {
+      unsubscribe: () => {
+        stopped = true;
+        if (inner) {
+          inner.unsubscribe();
+          inner = null;
+        }
+        // Release handler references to prevent memory leaks
+        handlers = {};
+      },
+    };
+  }
+ 
+  // Private helpers
+ 
+  private _ensureCanMutate(): void {
+    if (!this.activeWallet && !this.config.signer && !this.config.keypair) {
+      throw new Error('keypair, wallet adapter, or signer is required for mutating operations');
+    }
+  }
+ 
+  private async _getSenderAddress(): Promise<string> {
+    if (this.activeWallet) {
+      return this.activeWallet.getPublicKey();
+    }
+    if (this.config.signer) {
+      return this.config.signer.publicKey();
+    }
+    if (this.config.keypair) {
+      return this.config.keypair.publicKey();
+    }
+    throw new Error('keypair, wallet adapter, or signer is required for mutating operations');
+  }
+ 
+  private async _signTx(tx: Transaction): Promise<Transaction> {
+    if (this.activeWallet) {
+      const signed = await this.activeWallet.signTransaction(tx, {
+        networkPassphrase: this.passphrase,
+      });
+      if (signed == null) {
+        throw new Error('Wallet adapter signTransaction returned null or undefined');
+      }
+      Iif (typeof signed === 'string') {
+        return new Transaction(signed, this.passphrase);
+      }
+      return signed;
+    }
+    if (this.config.signer) {
+      const result = this.config.signer.sign(tx);
+      if (result != null) {
+        await result;
+      }
+      return tx;
+    }
+    Eif (this.config.keypair) {
+      tx.sign(this.config.keypair);
+      return tx;
+    }
+    throw new Error('keypair, wallet adapter, or signer is required for mutating operations');
+  }
+ 
+  private _server(): SorobanRpc.Server {
+    if (!this._cachedServer) {
+      this._cachedServer = new SorobanRpc.Server(this.rpcUrl, {
+        allowHttp: this.rpcUrl.startsWith('http://'),
+      });
+    }
+    return this._cachedServer;
+  }
+ 
+  private async _resolveAddr(id: bigint): Promise<string> {
+    const addr = await this._factory.streamAddress(id);
+    if (!addr) throw new ConduitError('stream', StreamErrorCode.StreamNotFound, `Stream ${id} not found`);
+    return addr;
+  }
+ 
+  private async _simulateTx(tx: Transaction): Promise<xdr.ScVal> {
+    const server = this._server();
+    const result = await server.simulateTransaction(tx);
+    if (SorobanRpc.Api.isSimulationError(result)) {
+      throw ConduitError.fromSorobanMessage('stream', result.error);
+    }
+    Iif (!result.result) throw new Error('Simulation returned no result');
+    return xdr.ScVal.fromXDR(result.result.retval.toXDR());
+  }
+ 
+  /** Simulate -> assemble -> sign -> submit -> poll. Returns txHash. */
+  private async _invoke(contractId: string, method: string, args: xdr.ScVal[]): Promise<string> {
+    const senderAddr = await this._getSenderAddress();
+    const tx         = await buildContractCallTx(this.rpcUrl, this.passphrase, senderAddr, contractId, method, args);
+    const server     = this._server();
+    const sim        = await server.simulateTransaction(tx);
+    Iif (SorobanRpc.Api.isSimulationError(sim)) {
+      throw ConduitError.fromSorobanMessage('stream', sim.error);
+    }
+    const assembled = SorobanRpc.assembleTransaction(tx, sim).build();
+    const signed    = await this._signTx(assembled);
+    const { hash }  = await this._sendAndPoll(server, signed);
+    return hash;
+  }
+ 
+  private async _sendAndPoll(
+    server: SorobanRpc.Server,
+    tx: Transaction,
+  ): Promise<{ hash: string; returnValue: xdr.ScVal | undefined }> {
+    let sent;
+    try {
+      sent = await server.sendTransaction(tx);
+    } catch (err) {
+      throw RateLimitError.fromRpcError(err) ?? err;
+    }
+    if (sent.status === 'ERROR') {
+      throw new Error(`Transaction rejected: ${JSON.stringify(sent.errorResult)}`);
+    }
+    const hash = sent.hash;
+    const maxAttempts = this.config.confirmationMaxAttempts ?? DEFAULT_CONFIRMATION_MAX_ATTEMPTS;
+    const pollIntervalMs = this.config.confirmationPollIntervalMs ?? DEFAULT_CONFIRMATION_POLL_INTERVAL_MS;
+    for (let i = 0; i < maxAttempts; i++) {
+      await sleep(pollIntervalMs);
+      let s;
+      try {
+        s = await server.getTransaction(hash);
+      } catch (err) {
+        throw RateLimitError.fromRpcError(err) ?? err;
+      }
+      if (s.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) {
+        return { hash, returnValue: s.returnValue };
+      }
+      if (s.status === SorobanRpc.Api.GetTransactionStatus.FAILED) {
+        throw new Error(`Transaction failed: ${hash}`);
+      }
+    }
+    throw new Error(`Transaction timed out: ${hash}`);
+  }
+}
+ 
+// Parsing
+ 
+function parseStreamInfo(id: bigint, address: string, val: xdr.ScVal): StreamInfo {
+  const entries = val.map() ?? [];
+  const m: Record<string, xdr.ScVal> = {};
+  for (const e of entries) {
+    const k = e.key().sym()?.toString('utf8') ?? e.key().str()?.toString('utf8') ?? '';
+    m[k] = e.val();
+  }
+  const info: StreamInfo = {
+    id,
+    address,
+    sender:          m['sender']          ? Address.fromScVal(m['sender']).toString()          : '',
+    recipient:       m['recipient']       ? Address.fromScVal(m['recipient']).toString()       : '',
+    token:           m['token']           ? Address.fromScVal(m['token']).toString()           : '',
+    ratePerSecond:   m['rate_per_second'] ? scValToI128(m['rate_per_second'])                 : 0n,
+    startTime:       m['start_time']      ? Number(scValToU64(m['start_time']))               : 0,
+    endTime:         m['end_time']        ? Number(scValToU64(m['end_time']))                 : 0,
+    withdrawn:       m['withdrawn']       ? scValToI128(m['withdrawn'])                       : 0n,
+    paused:          m['paused']?.b()     ?? false,
+    pausedAt:        m['paused_at']       ? Number(scValToU64(m['paused_at']))                : 0,
+    cancelled:       m['cancelled']?.b()  ?? false,
+    clawbackEnabled: m['clawback_enabled']?.b() ?? false,
+  };
+  (info as StreamInfo & { toJSON(): Record<string, unknown> }).toJSON = () => bigintSafeStringify(info as unknown as Record<string, unknown>);
+  return info;
+}
+ 
+function sleep(ms: number): Promise<void> {
+  return new Promise(r => setTimeout(r, ms));
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/types/index.html b/coverage/lcov-report/src/types/index.html new file mode 100644 index 0000000..75bc611 --- /dev/null +++ b/coverage/lcov-report/src/types/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for src/types + + + + + + + + + +
+
+

All files src/types

+
+ +
+ 0% + Statements + 0/0 +
+ + +
+ 0% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/0 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
index.ts +
+
0%0/00%0/00%0/00%0/0
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/types/index.ts.html b/coverage/lcov-report/src/types/index.ts.html new file mode 100644 index 0000000..eee44b6 --- /dev/null +++ b/coverage/lcov-report/src/types/index.ts.html @@ -0,0 +1,556 @@ + + + + + + Code coverage report for src/types/index.ts + + + + + + + + + +
+
+

All files / src/types index.ts

+
+ +
+ 0% + Statements + 0/0 +
+ + +
+ 0% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/0 +
+ + +
+ 0% + Lines + 0/0 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import type { Signer } from '../signer.js';
+ 
+export type Network = 'mainnet' | 'testnet' | 'local';
+ 
+/** Configuration returned by StreamBuilder.build() */
+export interface StreamConfig {
+  token: string;
+  sender: string;
+  recipient: string;
+  amount: number;
+  ratePerSecond?: string;
+}
+ 
+/** A Stellar account address (G... string) */
+export type Recipient = string;
+ 
+export interface ConduitConfig {
+  /** Network to connect to */
+  network: Network;
+  /** Signing keypair — required for mutating operations */
+  keypair?: import('@stellar/stellar-sdk').Keypair;
+  /** Custom wallet adapter (e.g. WalletConnectAdapter) for browser/mobile wallet signing */
+  wallet?: import('../adapters/types.js').WalletAdapter;
+  /** Custom signer plugin (KMS/HSM). Takes precedence over keypair when set. */
+  signer?: Signer;
+  /** Override default Soroban RPC URL */
+  rpcUrl?: string;
+  /** Override deployed DripFactory contract ID */
+  factoryAddress?: string;
+  /** Override deployed DripGovernor contract ID */
+  governorAddress?: string;
+  /** Override Soroban confirmation poll interval in ms; default 1000 */
+  confirmationPollIntervalMs?: number;
+  /** Override Soroban confirmation polling attempts; default 30 */
+  confirmationMaxAttempts?: number;
+}
+ 
+export interface StreamInfo {
+  id:              bigint;
+  /** DripStream contract address */
+  address:         string;
+  sender:          string;
+  recipient:       string;
+  /** Token contract address or 'native' */
+  token:           string;
+  /** Tokens released per second, in stroops */
+  ratePerSecond:   bigint;
+  /** Unix timestamp */
+  startTime:       number;
+  /** Unix timestamp; 0 = open-ended */
+  endTime:         number;
+  /** Total withdrawn by recipient so far, in stroops */
+  withdrawn:       bigint;
+  paused:          boolean;
+  /** Timestamp when stream was last paused; 0 if not paused */
+  pausedAt:        number;
+  cancelled:       boolean;
+  clawbackEnabled: boolean;
+}
+ 
+/** On-chain state of a stream — alias for StreamInfo */
+export type StreamState = StreamInfo;
+ 
+export interface CreateStreamParams {
+  /** Stellar recipient address */
+  recipient: string;
+  /** 'native' (XLM), 'USDC', or a contract address */
+  token: string;
+  /** Total deposit in display units (e.g. '1000') */
+  depositAmount: string;
+  /** Stream duration in seconds (mutually exclusive with ratePerSecond) */
+  durationSeconds?: number;
+  /** Unix timestamp; defaults to current ledger time */
+  startTime?: number;
+  /** Whether the sender can claw back unstreamed tokens */
+  clawbackEnabled?: boolean;
+  /** Override rate in stroops/s (mutually exclusive with durationSeconds) */
+  ratePerSecond?: string;
+}
+ 
+export interface CreateStreamResult {
+  streamId:      bigint;
+  streamAddress: string;
+  txHash:        string;
+}
+ 
+export interface ListStreamsParams {
+  sender?:    string;
+  recipient?: string;
+  offset?:    number;
+  limit?:     number;
+  /** Opaque pagination cursor from a previous page's `nextCursor`. Takes precedence over `offset` if both are given. */
+  cursor?:    string;
+}
+ 
+export interface PaginatedStreams {
+  /** The stream info results for this page */
+  streams:    StreamInfo[];
+  /** Whether there are more results after this page */
+  hasNextPage: boolean;
+  /** Number of filtered stream IDs seen through the end of this page */
+  totalCount:  bigint;
+  /** The offset used for this page */
+  offset:      number;
+  /** The limit used for this page */
+  limit:       number;
+  /** Opaque cursor to pass as `cursor` to fetch the next page. Present only when `hasNextPage` is true. */
+  nextCursor?: string;
+}
+ 
+export interface GovernorConfig {
+  feeBps:              number;
+  feeRecipient?:       string;
+  minDurationSeconds:  number;
+  maxRatePerSecond:    bigint;
+  factoryAddress?:     string;
+}
+ 
+// ── Events ──────────────────────────────────────────────────────────────────
+ 
+export interface WithdrawEvent  { amount: bigint; recipient: string; totalWithdrawn: bigint; remaining: bigint; }
+export interface CancelEvent    { refundAmount: bigint; withdrawnSoFar: bigint; sender: string; }
+export interface PauseEvent     { pausedAt: number; withdrawable: bigint; sender: string; }
+export interface ResumeEvent    { resumedAt: number; sender: string; }
+export interface TopUpEvent     { amount: bigint; newBalance: bigint; sender: string; }
+export interface ClawbackEvent  { amount: bigint; sender: string; }
+ 
+export interface StreamEventHandlers {
+  onWithdraw?: (e: WithdrawEvent)  => void;
+  onCancel?:   (e: CancelEvent)    => void;
+  onPause?:    (e: PauseEvent)     => void;
+  onResume?:   (e: ResumeEvent)    => void;
+  onTopUp?:    (e: TopUpEvent)     => void;
+  onClawback?: (e: ClawbackEvent)  => void;
+  onError?:    (error: Error)      => void;
+  /** Polling interval in ms; default 5000 */
+  pollInterval?: number;
+}
+ 
+export interface Subscription {
+  unsubscribe: () => void;
+}
+ 
+// -- Batch operations ----------------------------------------------------------
+ 
+export interface BatchWithdrawItem {
+  streamId: bigint | string;
+  /** Amount in stroops. Defaults to the full withdrawable balance if omitted. */
+  amount?: bigint;
+}
+ 
+export interface BatchWithdrawResult {
+  streamId: bigint;
+  success: boolean;
+  txHash?: string;
+  error?: string;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/src/utils.ts.html b/coverage/lcov-report/src/utils.ts.html new file mode 100644 index 0000000..7c9f99c --- /dev/null +++ b/coverage/lcov-report/src/utils.ts.html @@ -0,0 +1,571 @@ + + + + + + Code coverage report for src/utils.ts + + + + + + + + + +
+
+

All files / src utils.ts

+
+ +
+ 97.43% + Statements + 76/78 +
+ + +
+ 92.98% + Branches + 53/57 +
+ + +
+ 100% + Functions + 9/9 +
+ + +
+ 100% + Lines + 66/66 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163  +  +  +29x +  +  +41x +41x +3x +3x +  +41x +  +  +  +  +  +  +  +  +34x +34x +20x +  +14x +34x +34x +  +34x +34x +4x +4x +3x +  +  +14x +  +  +  +  +7x +7x +7x +7x +26x +7x +7x +  +  +  +  +  +  +  +  +  +  +14x +14x +14x +13x +13x +  +13x +  +  +  +  +  +  +  +  +4x +4x +3x +2x +1x +  +  +  +  +  +  +  +6x +  +5x +  +  +  +  +  +6x +  +4x +4x +  +4x +4x +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +573x +  +  +  +2145x +  +299x +  +1846x +685x +685x +  +685x +2x +2x +2x +8x +  +2x +  +  +683x +683x +683x +683x +1564x +1564x +  +683x +  +  +  +  +  +  +  +  +  +  +  +  +8x +3x +  +5x +  + 
import type { StreamInfo } from './types/index.js';
+import { StrKey } from '@stellar/stellar-sdk';
+ 
+const _powCache: Record<number, bigint> = {};
+ 
+function _pow10(decimals: number): bigint {
+  let p = _powCache[decimals];
+  if (!p) {
+    p = BigInt(10) ** BigInt(decimals);
+    _powCache[decimals] = p;
+  }
+  return p;
+}
+ 
+/** Convert a display amount string to stroops (bigint).
+ *
+ *  Cache-friendly — `10 ** decimals` is computed once per unique `decimals`
+ *  value and reused across calls, avoiding repeated BigInt exponentiation.
+ */
+export function toStroops(amount: string, decimals = 7): bigint {
+  const dot = amount.indexOf('.');
+  if (dot === -1) {
+    return BigInt(amount) * _pow10(decimals);
+  }
+  const whole = amount.slice(0, dot) || '0';
+  const frac  = amount.slice(dot + 1, dot + 1 + decimals);
+  const rest  = amount.slice(dot + 1 + decimals);
+ 
+  let base = BigInt(whole) * _pow10(decimals) + BigInt(frac.padEnd(decimals, '0'));
+  if (rest) {
+    const roundingDigit = rest.charCodeAt(0) /* '0' … '9' */;
+    if (roundingDigit >= 53 /* charCode of '5' */) {
+      base += 1n;
+    }
+  }
+  return base;
+}
+ 
+/** Convert stroops (bigint) to a display amount string */
+export function fromStroops(stroops: bigint, decimals = 7): string {
+  const factor = _pow10(decimals);
+  const whole  = stroops / factor;
+  const frac   = (stroops % factor).toString().padStart(decimals, '0');
+  let end = frac.length;
+  while (end > 0 && frac.charCodeAt(end - 1) === 48 /* '0' */) end--;
+  const trimmed = end === 0 ? '0' : frac.slice(0, end);
+  return `${whole}.${trimmed}`;
+}
+ 
+/**
+ * Calculate the rate per second (in stroops) from a total deposit and duration.
+ *
+ * @param depositAmount  Display amount string, e.g. '1000'
+ * @param durationSecs   Duration in seconds
+ * @param decimals       Token decimal places (default 7 for Stellar assets)
+ */
+export function calculateRate(depositAmount: string, durationSecs: number, decimals = 7): bigint {
+  const stroops = toStroops(depositAmount, decimals);
+  const divisor = BigInt(durationSecs);
+  if (divisor === 0n) return 0n;
+  const quotient = stroops / divisor;
+  const remainder = stroops % divisor;
+  // Round to nearest: if remainder >= half the divisor, round up
+  return remainder * 2n >= divisor ? quotient + 1n : quotient;
+}
+ 
+/**
+ * Current progress fraction (0-1) of a stream.
+ * Returns 0 if not started, 1 if ended, and NaN for open-ended streams
+ * that have started but do not have a finite completion percentage.
+ */
+export function streamProgress(stream: StreamInfo, nowSec = Math.floor(Date.now() / 1000)): number {
+  const { startTime, endTime } = stream;
+  if (nowSec < startTime) return 0;
+  if (endTime === 0)       return Number.NaN;  // open-ended
+  if (nowSec >= endTime)   return 1;
+  return (nowSec - startTime) / (endTime - startTime);
+}
+ 
+/**
+ * Current withdrawable balance from a StreamInfo snapshot, without a contract call.
+ * Accounts for pause state.
+ */
+export function withdrawableLocal(stream: StreamInfo, nowSec = Math.floor(Date.now() / 1000)): bigint {
+  if (stream.cancelled) return 0n;
+ 
+  const effectiveNow = stream.paused
+    ? stream.pausedAt
+    : stream.endTime > 0 && nowSec > stream.endTime
+    ? stream.endTime
+    : nowSec;
+ 
+  if (effectiveNow < stream.startTime) return 0n;
+ 
+  const elapsed = effectiveNow - stream.startTime;
+  Iif (elapsed <= 0) return 0n;
+ 
+  const streamed = stream.ratePerSecond * BigInt(elapsed);
+  const available = streamed - stream.withdrawn;
+  return available > 0n ? available : 0n;
+}
+ 
+/**
+ * Recursively convert all bigint values in a value to their string
+ * representation.  Safe for objects, arrays, and primitives.
+ *
+ * Safari / WebKit serialises `bigint` values as `{}` inside
+ * `JSON.stringify`, which breaks payloads sent to the GraphQL
+ * indexer.  Call this before network submission to guarantee
+ * interoperability across all browsers.
+ *
+ * Uses a `WeakSet` to handle circular references without infinite
+ * recursion, and iterates with indexed loops instead of `.map()` /
+ * `.entries()` to minimise per-element allocations.
+ */
+export function bigintSafeStringify<T>(value: T): T {
+  return _safeStringify(value, new WeakSet());
+}
+ 
+function _safeStringify<T>(value: T, visited: WeakSet<object>): T {
+  if (typeof value === 'bigint') {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    return value.toString() as any;
+  }
+  if (value === null || typeof value !== 'object') return value;
+  Iif (visited.has(value)) return value as T;
+  visited.add(value);
+ 
+  if (Array.isArray(value)) {
+    const { length } = value;
+    const out = new Array<unknown>(length);
+    for (let i = 0; i < length; i++) {
+      out[i] = _safeStringify(value[i], visited);
+    }
+    return out as unknown as T;
+  }
+ 
+  const obj = value as Record<string, unknown>;
+  const keys = Object.keys(obj);
+  const out: Record<string, unknown> = {};
+  for (let i = 0; i < keys.length; i++) {
+    const k = keys[i];
+    out[k] = _safeStringify(obj[k], visited);
+  }
+  return out as T;
+}
+ 
+/**
+ * Validates whether a string is a well-formed Stellar public key
+ * (account address, e.g. 'GABC...XYZ').
+ *
+ * Performs static format validation only (StrKey encoding, version byte,
+ * checksum) -- it does not check whether the account exists on-chain.
+ * Use this to fail fast before submission, e.g. before passing a recipient
+ * into client.streams.create().
+ */
+export function isValidAddress(address: string): boolean {
+  if (typeof address !== 'string' || address.length === 0) {
+    return false;
+  }
+  return StrKey.isValidEd25519PublicKey(address);
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov.info b/coverage/lcov.info new file mode 100644 index 0000000..52a1088 --- /dev/null +++ b/coverage/lcov.info @@ -0,0 +1,3572 @@ +TN: +SF:src/builder.ts +FN:23,constructor +FN:27,acquire +FN:32,(anonymous_2) +FN:35,release +FN:58,constructor_2 +FN:68,token +FN:78,sender +FN:88,recipient +FN:98,amount +FN:114,ratePerSecond +FN:135,build +FN:169,submit +FN:227,(anonymous_12) +FN:228,(anonymous_13) +FN:235,(anonymous_14) +FN:258,getPendingQueue +FN:262,cleanup +FN:271,_validateAddress +FN:320,validatePayload +FN:403,execute +FN:437,executeAsync +FN:445,(anonymous_21) +FN:449,(anonymous_22) +FN:462,processQueue +FN:473,(anonymous_24) +FN:499,(anonymous_25) +FN:523,cleanup_2 +FN:529,(anonymous_27) +FN:543,destroy +FN:552,reset +FNF:30 +FNH:28 +FNDA:52,constructor +FNDA:16,acquire +FNDA:8,(anonymous_2) +FNDA:16,release +FNDA:52,constructor_2 +FNDA:36,token +FNDA:32,sender +FNDA:30,recipient +FNDA:28,amount +FNDA:9,ratePerSecond +FNDA:29,build +FNDA:20,submit +FNDA:3,(anonymous_12) +FNDA:3,(anonymous_13) +FNDA:0,(anonymous_14) +FNDA:3,getPendingQueue +FNDA:4,cleanup +FNDA:8,_validateAddress +FNDA:120,validatePayload +FNDA:8,execute +FNDA:8,executeAsync +FNDA:66,(anonymous_21) +FNDA:66,(anonymous_22) +FNDA:8,processQueue +FNDA:0,(anonymous_24) +FNDA:58,(anonymous_25) +FNDA:8,cleanup_2 +FNDA:1,(anonymous_27) +FNDA:8,destroy +FNDA:8,reset +DA:15,8 +DA:16,8 +DA:19,52 +DA:20,52 +DA:24,52 +DA:28,16 +DA:29,8 +DA:30,8 +DA:32,8 +DA:36,16 +DA:37,16 +DA:38,8 +DA:39,8 +DA:52,52 +DA:53,52 +DA:54,52 +DA:59,52 +DA:60,52 +DA:69,36 +DA:70,36 +DA:79,32 +DA:80,32 +DA:89,30 +DA:90,30 +DA:99,28 +DA:100,6 +DA:102,22 +DA:103,22 +DA:115,9 +DA:116,4 +DA:117,3 +DA:120,5 +DA:121,4 +DA:124,2 +DA:125,2 +DA:136,29 +DA:137,1 +DA:139,28 +DA:143,5 +DA:146,23 +DA:152,23 +DA:153,2 +DA:156,23 +DA:173,20 +DA:174,1 +DA:176,19 +DA:177,1 +DA:180,18 +DA:181,18 +DA:182,1 +DA:186,17 +DA:187,1 +DA:193,16 +DA:194,16 +DA:195,16 +DA:197,16 +DA:198,20 +DA:200,20 +DA:201,16 +DA:202,16 +DA:205,16 +DA:206,19 +DA:207,0 +DA:210,19 +DA:211,0 +DA:214,19 +DA:215,19 +DA:216,13 +DA:217,13 +DA:219,6 +DA:220,1 +DA:222,5 +DA:223,5 +DA:224,5 +DA:226,3 +DA:227,3 +DA:228,3 +DA:229,3 +DA:230,3 +DA:232,3 +DA:234,3 +DA:235,0 +DA:236,0 +DA:237,0 +DA:238,0 +DA:239,0 +DA:241,0 +DA:248,2 +DA:254,16 +DA:259,3 +DA:263,4 +DA:264,4 +DA:265,0 +DA:267,4 +DA:268,4 +DA:272,98 +DA:273,9 +DA:276,89 +DA:277,31 +DA:278,2 +DA:284,58 +DA:285,3 +DA:291,84 +DA:312,8 +DA:321,120 +DA:323,120 +DA:324,7 +DA:325,7 +DA:328,113 +DA:329,6 +DA:330,6 +DA:333,107 +DA:334,562 +DA:335,562 +DA:336,8 +DA:337,554 +DA:338,4 +DA:341,550 +DA:344,550 +DA:345,148 +DA:346,148 +DA:347,3 +DA:352,550 +DA:353,144 +DA:354,144 +DA:355,3 +DA:360,550 +DA:361,144 +DA:362,144 +DA:363,3 +DA:368,550 +DA:369,143 +DA:370,143 +DA:371,4 +DA:377,107 +DA:387,8 +DA:388,8 +DA:389,8 +DA:390,8 +DA:407,63 +DA:408,4 +DA:411,59 +DA:412,59 +DA:413,30 +DA:421,29 +DA:422,29 +DA:423,63 +DA:425,63 +DA:441,67 +DA:442,1 +DA:445,66 +DA:446,66 +DA:447,66 +DA:449,66 +DA:450,66 +DA:452,66 +DA:454,66 +DA:455,66 +DA:458,66 +DA:463,65 +DA:464,65 +DA:466,65 +DA:467,65 +DA:468,65 +DA:470,65 +DA:472,65 +DA:473,65 +DA:474,65 +DA:475,5 +DA:476,4 +DA:478,1 +DA:482,65 +DA:483,65 +DA:484,4 +DA:485,4 +DA:488,61 +DA:489,61 +DA:490,3 +DA:496,3 +DA:499,58 +DA:504,58 +DA:510,65 +DA:511,1 +DA:516,65 +DA:524,100 +DA:526,100 +DA:527,100 +DA:529,100 +DA:530,1 +DA:533,100 +DA:534,0 +DA:536,100 +DA:544,9 +DA:545,9 +DA:553,90 +DA:554,90 +LF:196 +LH:186 +BRDA:28,0,0,8 +BRDA:28,0,1,8 +BRDA:37,1,0,8 +BRDA:37,1,1,8 +BRDA:59,2,0,52 +BRDA:59,2,1,50 +BRDA:60,3,0,52 +BRDA:60,3,1,51 +BRDA:99,4,0,6 +BRDA:99,4,1,22 +BRDA:99,5,0,28 +BRDA:99,5,1,26 +BRDA:115,6,0,4 +BRDA:115,6,1,5 +BRDA:116,7,0,3 +BRDA:116,7,1,1 +BRDA:120,8,0,4 +BRDA:120,8,1,1 +BRDA:120,9,0,5 +BRDA:120,9,1,4 +BRDA:136,10,0,1 +BRDA:136,10,1,28 +BRDA:139,11,0,5 +BRDA:139,11,1,23 +BRDA:139,12,0,28 +BRDA:139,12,1,26 +BRDA:139,12,2,26 +BRDA:139,12,3,25 +BRDA:139,12,4,25 +BRDA:139,12,5,24 +BRDA:139,12,6,24 +BRDA:139,12,7,23 +BRDA:152,13,0,2 +BRDA:152,13,1,21 +BRDA:152,14,0,23 +BRDA:152,14,1,2 +BRDA:171,15,0,20 +BRDA:173,16,0,1 +BRDA:173,16,1,19 +BRDA:176,17,0,1 +BRDA:176,17,1,18 +BRDA:181,18,0,1 +BRDA:181,18,1,17 +BRDA:186,19,0,1 +BRDA:186,19,1,16 +BRDA:197,20,0,16 +BRDA:197,20,1,12 +BRDA:198,21,0,20 +BRDA:198,21,1,12 +BRDA:206,22,0,0 +BRDA:206,22,1,19 +BRDA:210,23,0,0 +BRDA:210,23,1,19 +BRDA:219,24,0,1 +BRDA:219,24,1,5 +BRDA:224,25,0,3 +BRDA:224,25,1,2 +BRDA:234,26,0,0 +BRDA:234,26,1,3 +BRDA:250,27,0,2 +BRDA:250,27,1,0 +BRDA:272,28,0,9 +BRDA:272,28,1,89 +BRDA:272,29,0,98 +BRDA:272,29,1,97 +BRDA:276,30,0,31 +BRDA:276,30,1,58 +BRDA:277,31,0,2 +BRDA:277,31,1,29 +BRDA:284,32,0,3 +BRDA:284,32,1,55 +BRDA:323,33,0,7 +BRDA:323,33,1,113 +BRDA:323,34,0,120 +BRDA:323,34,1,115 +BRDA:328,35,0,6 +BRDA:328,35,1,107 +BRDA:335,36,0,8 +BRDA:335,36,1,554 +BRDA:335,37,0,562 +BRDA:335,37,1,556 +BRDA:337,38,0,4 +BRDA:337,38,1,550 +BRDA:344,39,0,148 +BRDA:344,39,1,402 +BRDA:344,40,0,550 +BRDA:344,40,1,148 +BRDA:346,41,0,3 +BRDA:346,41,1,145 +BRDA:352,42,0,144 +BRDA:352,42,1,406 +BRDA:352,43,0,550 +BRDA:352,43,1,144 +BRDA:354,44,0,3 +BRDA:354,44,1,141 +BRDA:360,45,0,144 +BRDA:360,45,1,406 +BRDA:360,46,0,550 +BRDA:360,46,1,144 +BRDA:362,47,0,3 +BRDA:362,47,1,141 +BRDA:368,48,0,143 +BRDA:368,48,1,407 +BRDA:368,49,0,550 +BRDA:368,49,1,143 +BRDA:370,50,0,4 +BRDA:370,50,1,139 +BRDA:370,51,0,143 +BRDA:370,51,1,143 +BRDA:407,52,0,4 +BRDA:407,52,1,59 +BRDA:412,53,0,30 +BRDA:412,53,1,29 +BRDA:422,54,0,29 +BRDA:422,54,1,24 +BRDA:423,55,0,4 +BRDA:423,55,1,25 +BRDA:441,56,0,1 +BRDA:441,56,1,66 +BRDA:454,57,0,66 +BRDA:454,57,1,0 +BRDA:463,58,0,0 +BRDA:463,58,1,65 +BRDA:463,59,0,65 +BRDA:463,59,1,65 +BRDA:466,60,0,65 +BRDA:466,60,1,65 +BRDA:468,61,0,0 +BRDA:468,61,1,65 +BRDA:474,62,0,5 +BRDA:474,62,1,60 +BRDA:475,63,0,4 +BRDA:475,63,1,1 +BRDA:483,64,0,4 +BRDA:483,64,1,61 +BRDA:489,65,0,3 +BRDA:489,65,1,58 +BRDA:510,66,0,1 +BRDA:510,66,1,64 +BRDA:510,67,0,65 +BRDA:510,67,1,5 +BRF:141 +BRH:134 +end_of_record +TN: +SF:src/client.ts +FN:35,assertWalletNetworkMatch +FN:63,constructor +FN:105,setWallet +FNF:3 +FNH:3 +FNDA:11,assertWalletNetworkMatch +FNDA:26,constructor +FNDA:8,setWallet +DA:15,15 +DA:39,11 +DA:40,11 +DA:42,9 +DA:43,9 +DA:45,9 +DA:46,1 +DA:49,8 +DA:50,4 +DA:66,26 +DA:67,9 +DA:70,17 +DA:76,26 +DA:77,3 +DA:80,16 +DA:81,16 +DA:82,16 +DA:106,8 +DA:107,8 +DA:108,8 +LF:20 +LH:20 +BRDA:40,0,0,2 +BRDA:40,0,1,9 +BRDA:40,1,0,11 +BRDA:40,1,1,9 +BRDA:45,2,0,1 +BRDA:45,2,1,8 +BRDA:49,3,0,4 +BRDA:49,3,1,4 +BRDA:66,4,0,9 +BRDA:66,4,1,17 +BRDA:72,5,0,17 +BRDA:72,5,1,17 +BRDA:76,6,0,3 +BRDA:76,6,1,23 +BRF:14 +BRH:14 +end_of_record +TN: +SF:src/constants.ts +FNF:0 +FNH:0 +DA:8,30 +LF:1 +LH:1 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/errors.ts +FN:21,(anonymous_0) +FN:59,(anonymous_1) +FN:87,(anonymous_2) +FN:136,constructor +FN:154,constructor_2 +FN:166,isKnown +FN:171,fromContractError +FN:188,fromSorobanMessage +FN:208,constructor_3 +FN:215,parseRetryAfterMs +FN:247,fromRpcError +FNF:11 +FNH:11 +FNDA:39,(anonymous_0) +FNDA:39,(anonymous_1) +FNDA:39,(anonymous_2) +FNDA:29,constructor +FNDA:50,constructor_2 +FNDA:2,isKnown +FNDA:39,fromContractError +FNDA:39,fromSorobanMessage +FNDA:8,constructor_3 +FNDA:39,parseRetryAfterMs +FNDA:39,fromRpcError +DA:17,39 +DA:21,39 +DA:22,39 +DA:23,39 +DA:24,39 +DA:25,39 +DA:26,39 +DA:27,39 +DA:28,39 +DA:29,39 +DA:30,39 +DA:31,39 +DA:32,39 +DA:33,39 +DA:34,39 +DA:35,39 +DA:36,39 +DA:39,39 +DA:59,39 +DA:60,39 +DA:61,39 +DA:62,39 +DA:63,39 +DA:64,39 +DA:65,39 +DA:66,39 +DA:67,39 +DA:68,39 +DA:69,39 +DA:72,39 +DA:87,39 +DA:88,39 +DA:89,39 +DA:90,39 +DA:93,39 +DA:99,39 +DA:111,39 +DA:137,29 +DA:138,29 +DA:142,29 +DA:143,29 +DA:144,29 +DA:146,29 +DA:155,50 +DA:156,50 +DA:157,50 +DA:158,50 +DA:167,2 +DA:172,8 +DA:173,7 +DA:174,7 +DA:175,6 +DA:178,2 +DA:189,7 +DA:190,7 +DA:191,5 +DA:192,5 +DA:193,5 +DA:209,8 +DA:210,8 +DA:211,8 +DA:212,8 +DA:216,6 +DA:218,6 +DA:219,6 +DA:221,6 +DA:222,6 +DA:223,3 +DA:226,3 +DA:227,3 +DA:228,2 +DA:231,1 +DA:248,16 +DA:250,16 +DA:251,16 +DA:252,6 +DA:253,6 +DA:254,6 +DA:263,10 +DA:264,10 +DA:265,2 +DA:268,8 +LF:82 +LH:82 +BRDA:155,0,0,50 +BRDA:155,0,1,41 +BRDA:155,0,2,1 +BRDA:172,1,0,7 +BRDA:172,1,1,1 +BRDA:172,2,0,8 +BRDA:172,2,1,8 +BRDA:172,2,2,7 +BRDA:174,3,0,6 +BRDA:174,3,1,1 +BRDA:190,4,0,2 +BRDA:190,4,1,5 +BRDA:190,5,0,7 +BRDA:190,5,1,5 +BRDA:192,6,0,0 +BRDA:192,6,1,5 +BRDA:216,7,0,0 +BRDA:216,7,1,6 +BRDA:216,8,0,6 +BRDA:216,8,1,0 +BRDA:219,9,0,0 +BRDA:219,9,1,6 +BRDA:222,10,0,3 +BRDA:222,10,1,3 +BRDA:222,11,0,6 +BRDA:222,11,1,3 +BRDA:227,12,0,2 +BRDA:227,12,1,1 +BRDA:248,13,0,0 +BRDA:248,13,1,16 +BRDA:248,14,0,16 +BRDA:248,14,1,16 +BRDA:251,15,0,6 +BRDA:251,15,1,10 +BRDA:264,16,0,2 +BRDA:264,16,1,8 +BRDA:264,17,0,10 +BRDA:264,17,1,10 +BRF:38 +BRH:33 +end_of_record +TN: +SF:src/events.ts +FN:40,tupleFields +FN:44,i128Field +FN:49,u64Field +FN:60,addressField +FN:79,subscribeToStream +FN:90,poll +FN:105,(anonymous_6) +FN:124,(anonymous_7) +FN:144,dispatchEvent +FNF:9 +FNH:9 +FNDA:5,tupleFields +FNDA:10,i128Field +FNDA:1,u64Field +FNDA:10,addressField +FNDA:8,subscribeToStream +FNDA:10,poll +FNDA:2,(anonymous_6) +FNDA:8,(anonymous_7) +FNDA:12,dispatchEvent +DA:23,3 +DA:41,5 +DA:45,10 +DA:46,10 +DA:50,1 +DA:51,1 +DA:61,10 +DA:62,10 +DA:63,10 +DA:65,1 +DA:84,8 +DA:85,8 +DA:86,8 +DA:87,8 +DA:91,10 +DA:93,10 +DA:94,10 +DA:103,9 +DA:105,2 +DA:106,2 +DA:108,2 +DA:109,2 +DA:114,1 +DA:117,10 +DA:121,8 +DA:123,8 +DA:125,8 +DA:126,8 +DA:127,8 +DA:128,8 +DA:135,8 +DA:149,12 +DA:150,12 +DA:152,10 +DA:154,12 +DA:156,12 +DA:158,2 +DA:160,1 +DA:161,1 +DA:167,1 +DA:168,1 +DA:172,2 +DA:174,2 +DA:175,2 +DA:180,2 +DA:181,2 +DA:185,1 +DA:187,1 +DA:188,1 +DA:193,1 +DA:194,1 +DA:198,1 +DA:201,1 +DA:205,1 +DA:206,1 +DA:210,1 +DA:212,1 +DA:213,1 +DA:218,1 +DA:219,1 +DA:223,2 +DA:225,2 +DA:229,2 +DA:230,2 +LF:64 +LH:64 +BRDA:41,0,0,5 +BRDA:41,0,1,0 +BRDA:46,1,0,10 +BRDA:46,1,1,0 +BRDA:51,2,0,1 +BRDA:51,2,1,0 +BRDA:61,3,0,0 +BRDA:61,3,1,10 +BRDA:85,4,0,8 +BRDA:85,4,1,4 +BRDA:91,5,0,0 +BRDA:91,5,1,10 +BRDA:95,6,0,1 +BRDA:95,6,1,9 +BRDA:103,7,0,2 +BRDA:103,7,1,7 +BRDA:117,8,0,10 +BRDA:117,8,1,0 +BRDA:126,9,0,8 +BRDA:126,9,1,0 +BRDA:150,10,0,2 +BRDA:150,10,1,10 +BRDA:150,11,0,12 +BRDA:150,11,1,12 +BRDA:152,12,0,10 +BRDA:152,12,1,0 +BRDA:156,13,0,2 +BRDA:156,13,1,2 +BRDA:156,13,2,1 +BRDA:156,13,3,1 +BRDA:156,13,4,1 +BRDA:156,13,5,2 +BRDA:158,14,0,1 +BRDA:158,14,1,1 +BRDA:172,15,0,0 +BRDA:172,15,1,2 +BRDA:185,16,0,0 +BRDA:185,16,1,1 +BRDA:198,17,0,0 +BRDA:198,17,1,1 +BRDA:210,18,0,0 +BRDA:210,18,1,1 +BRDA:223,19,0,0 +BRDA:223,19,1,2 +BRF:44 +BRH:31 +end_of_record +TN: +SF:src/factory.ts +FN:23,constructor +FN:48,streamCount +FN:58,streamAddress +FN:77,streamsBySender +FN:92,streamsByRecipient +FN:107,protocolFeeBps +FN:116,parseU64Vec +FN:119,(anonymous_7) +FNF:8 +FNH:8 +FNDA:49,constructor +FNDA:2,streamCount +FNDA:2,streamAddress +FNDA:3,streamsBySender +FNDA:1,streamsByRecipient +FNDA:2,protocolFeeBps +FNDA:4,parseU64Vec +FNDA:4,(anonymous_7) +DA:23,49 +DA:27,49 +DA:28,1 +DA:30,48 +DA:31,49 +DA:36,49 +DA:37,1 +DA:41,47 +DA:44,47 +DA:49,2 +DA:53,2 +DA:54,2 +DA:59,2 +DA:60,2 +DA:65,2 +DA:68,2 +DA:69,1 +DA:70,1 +DA:72,0 +DA:78,3 +DA:87,3 +DA:88,3 +DA:93,1 +DA:102,1 +DA:103,1 +DA:108,2 +DA:112,2 +DA:113,2 +DA:117,4 +DA:118,4 +DA:119,4 +LF:31 +LH:30 +BRDA:27,0,0,1 +BRDA:27,0,1,48 +BRDA:30,1,0,48 +BRDA:30,1,1,13 +BRDA:36,2,0,1 +BRDA:36,2,1,48 +BRDA:44,3,0,47 +BRDA:44,3,1,40 +BRDA:68,4,0,1 +BRDA:68,4,1,1 +BRDA:77,5,0,3 +BRDA:77,6,0,3 +BRDA:92,7,0,1 +BRDA:92,8,0,1 +BRDA:118,9,0,0 +BRDA:118,9,1,4 +BRF:16 +BRH:15 +end_of_record +TN: +SF:src/fee-estimator.ts +FN:22,constructor +FN:35,estimateFee +FN:51,(anonymous_2) +FN:83,_isEstimating +FN:87,getBaseFee +FN:91,lastSuccessfulFetchAt +FN:95,lastError +FN:99,isStale +FNF:8 +FNH:8 +FNDA:16,constructor +FNDA:237,estimateFee +FNDA:19,(anonymous_2) +FNDA:1,_isEstimating +FNDA:2,getBaseFee +FNDA:3,lastSuccessfulFetchAt +FNDA:6,lastError +FNDA:7,isStale +DA:16,16 +DA:17,16 +DA:19,16 +DA:20,16 +DA:23,16 +DA:24,16 +DA:39,237 +DA:40,217 +DA:44,20 +DA:45,5 +DA:46,5 +DA:47,1 +DA:51,19 +DA:52,19 +DA:53,19 +DA:54,19 +DA:57,12 +DA:58,1 +DA:62,11 +DA:63,11 +DA:64,11 +DA:65,11 +DA:67,8 +DA:68,8 +DA:69,8 +DA:72,8 +DA:74,19 +DA:75,19 +DA:79,19 +DA:84,1 +DA:88,2 +DA:92,3 +DA:96,6 +DA:100,7 +LF:34 +LH:34 +BRDA:22,0,0,16 +BRDA:24,1,0,16 +BRDA:24,1,1,13 +BRDA:37,2,0,237 +BRDA:39,3,0,217 +BRDA:39,3,1,20 +BRDA:44,4,0,5 +BRDA:44,4,1,15 +BRDA:45,5,0,5 +BRDA:45,5,1,3 +BRDA:46,6,0,1 +BRDA:46,6,1,4 +BRDA:57,7,0,1 +BRDA:57,7,1,11 +BRDA:57,8,0,12 +BRDA:57,8,1,12 +BRDA:57,8,2,11 +BRDA:67,9,0,7 +BRDA:67,9,1,1 +BRF:19 +BRH:19 +end_of_record +TN: +SF:src/governor.ts +FN:31,constructor +FN:45,getConfig +FN:60,parseGovernorConfig +FNF:3 +FNH:3 +FNDA:22,constructor +FNDA:4,getConfig +FNDA:3,parseGovernorConfig +DA:34,22 +DA:35,1 +DA:37,21 +DA:38,22 +DA:39,22 +DA:40,22 +DA:41,22 +DA:46,4 +DA:47,1 +DA:51,3 +DA:55,3 +DA:56,3 +DA:61,3 +DA:62,3 +DA:63,3 +DA:64,7 +DA:65,7 +DA:67,3 +LF:18 +LH:18 +BRDA:34,0,0,1 +BRDA:34,0,1,21 +BRDA:37,1,0,21 +BRDA:37,1,1,3 +BRDA:40,2,0,22 +BRDA:40,2,1,21 +BRDA:46,3,0,1 +BRDA:46,3,1,3 +BRDA:61,4,0,3 +BRDA:61,4,1,0 +BRDA:64,5,0,7 +BRDA:64,5,1,0 +BRDA:64,5,2,0 +BRDA:68,6,0,3 +BRDA:68,6,1,2 +BRDA:69,7,0,1 +BRDA:69,7,1,2 +BRDA:70,8,0,1 +BRDA:70,8,1,2 +BRDA:74,9,0,2 +BRDA:74,9,1,1 +BRDA:75,10,0,2 +BRDA:75,10,1,1 +BRF:23 +BRH:20 +end_of_record +TN: +SF:src/graph-sync-agent.ts +FN:5,constructor +FN:14,syncState +FN:15,(anonymous_2) +FN:32,(anonymous_3) +FN:37,getCurrentState +FNF:5 +FNH:5 +FNDA:5,constructor +FNDA:155,syncState +FNDA:155,(anonymous_2) +FNDA:3,(anonymous_3) +FNDA:3,getCurrentState +DA:3,5 +DA:6,5 +DA:15,155 +DA:16,155 +DA:18,153 +DA:19,1 +DA:23,152 +DA:24,152 +DA:25,152 +DA:27,152 +DA:29,152 +DA:32,155 +DA:34,155 +DA:38,3 +LF:14 +LH:14 +BRDA:5,0,0,5 +BRDA:18,1,0,1 +BRDA:18,1,1,152 +BRDA:18,2,0,153 +BRDA:18,2,1,153 +BRF:5 +BRH:5 +end_of_record +TN: +SF:src/index.ts +FNF:0 +FNH:0 +LF:0 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/indexer.ts +FN:25,constructor +FN:32,query +FN:75,subscribe +FN:101,(anonymous_3) +FN:144,(anonymous_4) +FN:166,(anonymous_5) +FN:185,(anonymous_6) +FN:190,(anonymous_7) +FN:214,(anonymous_8) +FN:246,(anonymous_9) +FN:258,getSubscriptionCount +FN:262,cleanup +FN:270,getWebSocketCtor +FN:280,getWsUrl +FN:290,handleError +FNF:15 +FNH:15 +FNDA:67,constructor +FNDA:15,query +FNDA:46,subscribe +FNDA:42,(anonymous_3) +FNDA:3,(anonymous_4) +FNDA:9,(anonymous_5) +FNDA:11,(anonymous_6) +FNDA:12,(anonymous_7) +FNDA:8,(anonymous_8) +FNDA:1,(anonymous_9) +FNDA:16,getSubscriptionCount +FNDA:63,cleanup +FNDA:41,getWebSocketCtor +FNDA:33,getWsUrl +FNDA:12,handleError +DA:21,67 +DA:22,67 +DA:23,67 +DA:26,67 +DA:27,4 +DA:29,63 +DA:33,15 +DA:34,1 +DA:36,14 +DA:37,2 +DA:39,12 +DA:40,3 +DA:43,9 +DA:44,15 +DA:45,1 +DA:48,8 +DA:54,8 +DA:55,15 +DA:56,1 +DA:59,7 +DA:68,7 +DA:69,2 +DA:72,5 +DA:76,46 +DA:77,3 +DA:79,43 +DA:80,1 +DA:82,42 +DA:83,1 +DA:85,41 +DA:86,1 +DA:89,40 +DA:90,46 +DA:91,1 +DA:94,39 +DA:95,39 +DA:97,39 +DA:98,39 +DA:100,39 +DA:102,42 +DA:103,39 +DA:105,39 +DA:106,29 +DA:107,29 +DA:108,1 +DA:110,29 +DA:114,29 +DA:117,39 +DA:118,9 +DA:119,9 +DA:123,9 +DA:126,39 +DA:130,39 +DA:132,39 +DA:133,39 +DA:134,30 +DA:135,30 +DA:137,30 +DA:138,30 +DA:140,2 +DA:142,29 +DA:144,29 +DA:145,3 +DA:146,1 +DA:147,1 +DA:149,2 +DA:150,2 +DA:151,2 +DA:162,1 +DA:166,29 +DA:167,9 +DA:168,7 +DA:169,7 +DA:170,9 +DA:172,5 +DA:173,3 +DA:174,3 +DA:175,2 +DA:176,1 +DA:177,1 +DA:178,1 +DA:181,1 +DA:185,29 +DA:186,11 +DA:187,1 +DA:190,29 +DA:191,12 +DA:192,1 +DA:196,1 +DA:199,9 +DA:200,9 +DA:201,9 +DA:202,9 +DA:208,9 +DA:215,8 +DA:216,8 +DA:217,1 +DA:218,1 +DA:220,7 +DA:221,6 +DA:222,6 +DA:223,6 +DA:224,6 +DA:225,13 +DA:226,13 +DA:227,7 +DA:228,7 +DA:229,7 +DA:230,13 +DA:231,18 +DA:232,18 +DA:233,9 +DA:234,9 +DA:235,8 +DA:236,8 +DA:237,8 +DA:247,1 +DA:248,1 +DA:249,1 +DA:255,39 +DA:259,16 +DA:263,63 +DA:264,63 +DA:265,27 +DA:267,63 +DA:271,41 +DA:272,31 +DA:274,10 +DA:275,0 +DA:277,10 +DA:281,33 +DA:282,31 +DA:284,2 +DA:285,1 +DA:287,1 +DA:291,12 +DA:292,10 +DA:293,10 +LF:138 +LH:137 +BRDA:26,0,0,4 +BRDA:26,0,1,63 +BRDA:26,1,0,67 +BRDA:26,1,1,64 +BRDA:26,1,2,64 +BRDA:33,2,0,1 +BRDA:33,2,1,14 +BRDA:36,3,0,2 +BRDA:36,3,1,12 +BRDA:36,4,0,14 +BRDA:36,4,1,12 +BRDA:39,5,0,3 +BRDA:39,5,1,9 +BRDA:39,6,0,12 +BRDA:39,6,1,9 +BRDA:39,6,2,9 +BRDA:43,7,0,9 +BRDA:43,7,1,6 +BRDA:44,8,0,1 +BRDA:44,8,1,14 +BRDA:44,9,0,15 +BRDA:44,9,1,8 +BRDA:54,10,0,7 +BRDA:54,10,1,1 +BRDA:55,11,0,1 +BRDA:55,11,1,14 +BRDA:68,12,0,2 +BRDA:68,12,1,5 +BRDA:76,13,0,3 +BRDA:76,13,1,43 +BRDA:79,14,0,1 +BRDA:79,14,1,42 +BRDA:79,15,0,43 +BRDA:79,15,1,42 +BRDA:82,16,0,1 +BRDA:82,16,1,41 +BRDA:82,17,0,42 +BRDA:82,17,1,41 +BRDA:82,17,2,41 +BRDA:85,18,0,1 +BRDA:85,18,1,40 +BRDA:89,19,0,40 +BRDA:89,19,1,39 +BRDA:90,20,0,1 +BRDA:90,20,1,45 +BRDA:90,21,0,46 +BRDA:90,21,1,39 +BRDA:102,22,0,3 +BRDA:102,22,1,39 +BRDA:105,23,0,29 +BRDA:105,23,1,10 +BRDA:107,24,0,1 +BRDA:107,24,1,28 +BRDA:117,25,0,9 +BRDA:117,25,1,30 +BRDA:133,26,0,30 +BRDA:133,26,1,9 +BRDA:145,27,0,1 +BRDA:145,27,1,2 +BRDA:145,28,0,3 +BRDA:145,28,1,2 +BRDA:167,29,0,2 +BRDA:167,29,1,7 +BRDA:167,30,0,9 +BRDA:167,30,1,7 +BRDA:169,31,0,6 +BRDA:169,31,1,1 +BRDA:170,32,0,1 +BRDA:170,32,1,8 +BRDA:170,33,0,9 +BRDA:170,33,1,5 +BRDA:172,34,0,3 +BRDA:172,34,1,2 +BRDA:172,35,0,5 +BRDA:172,35,1,3 +BRDA:173,36,0,3 +BRDA:173,36,1,1 +BRDA:175,37,0,1 +BRDA:175,37,1,1 +BRDA:176,38,0,1 +BRDA:176,38,1,0 +BRDA:177,39,0,1 +BRDA:177,39,1,0 +BRDA:186,40,0,10 +BRDA:186,40,1,1 +BRDA:186,41,0,11 +BRDA:186,41,1,1 +BRDA:191,42,0,1 +BRDA:191,42,1,11 +BRDA:191,43,0,12 +BRDA:191,43,1,1 +BRDA:199,44,0,9 +BRDA:199,44,1,0 +BRDA:200,45,0,9 +BRDA:200,45,1,0 +BRDA:200,46,0,9 +BRDA:200,46,1,9 +BRDA:215,47,0,0 +BRDA:215,47,1,8 +BRDA:215,48,0,8 +BRDA:215,48,1,8 +BRDA:216,49,0,1 +BRDA:216,49,1,7 +BRDA:220,50,0,6 +BRDA:220,50,1,1 +BRDA:220,51,0,7 +BRDA:220,51,1,6 +BRDA:224,52,0,6 +BRDA:224,52,1,13 +BRDA:226,53,0,6 +BRDA:226,53,1,7 +BRDA:229,54,0,7 +BRDA:229,54,1,0 +BRDA:232,55,0,9 +BRDA:232,55,1,9 +BRDA:234,56,0,1 +BRDA:234,56,1,8 +BRDA:237,57,0,8 +BRDA:237,57,1,7 +BRDA:247,58,0,1 +BRDA:247,58,1,1 +BRDA:248,59,0,1 +BRDA:248,59,1,0 +BRDA:248,60,0,1 +BRDA:248,60,1,1 +BRDA:248,60,2,1 +BRDA:271,61,0,31 +BRDA:271,61,1,10 +BRDA:271,62,0,41 +BRDA:271,62,1,41 +BRDA:271,62,2,41 +BRDA:272,63,0,31 +BRDA:272,63,1,0 +BRDA:274,64,0,0 +BRDA:274,64,1,10 +BRDA:274,65,0,10 +BRDA:274,65,1,0 +BRDA:281,66,0,31 +BRDA:281,66,1,2 +BRDA:284,67,0,1 +BRDA:284,67,1,1 +BRDA:291,68,0,10 +BRDA:291,68,1,2 +BRDA:291,69,0,12 +BRDA:291,69,1,10 +BRDA:293,70,0,8 +BRDA:293,70,1,2 +BRF:147 +BRH:137 +end_of_record +TN: +SF:src/nonce-manager.ts +FN:5,constructor +FN:13,getNextNonce +FN:17,(anonymous_2) +FN:42,getCurrentNonce +FNF:4 +FNH:4 +FNDA:5,constructor +FNDA:156,getNextNonce +FNDA:156,(anonymous_2) +FNDA:1,getCurrentNonce +DA:3,5 +DA:6,5 +DA:14,156 +DA:17,156 +DA:18,156 +DA:21,156 +DA:23,156 +DA:24,156 +DA:25,154 +DA:27,156 +DA:28,1 +DA:29,1 +DA:32,153 +DA:33,153 +DA:35,2 +DA:36,2 +DA:38,156 +DA:43,1 +LF:18 +LH:18 +BRDA:5,0,0,5 +BRDA:25,1,0,153 +BRDA:25,1,1,1 +BRDA:27,2,0,1 +BRDA:27,2,1,155 +BRF:5 +BRH:5 +end_of_record +TN: +SF:src/signer.ts +FN:10,constructor +FN:11,sign +FN:14,publicKey +FN:39,constructor_2 +FN:46,normalizeTimeoutMs +FN:53,normalizeMaxPayloadSize +FN:60,isActive +FN:67,getChainId +FN:90,_signTypedData +FN:119,signProposal +FN:132,sign_2 +FN:140,(anonymous_11) +FN:143,(anonymous_12) +FN:150,(anonymous_13) +FN:159,(anonymous_14) +FN:170,(anonymous_15) +FN:177,publicKey_2 +FN:184,cleanup +FNF:18 +FNH:18 +FNDA:2,constructor +FNDA:1,sign +FNDA:1,publicKey +FNDA:52,constructor_2 +FNDA:5,normalizeTimeoutMs +FNDA:5,normalizeMaxPayloadSize +FNDA:3,isActive +FNDA:11,getChainId +FNDA:8,_signTypedData +FNDA:5,signProposal +FNDA:32,sign_2 +FNDA:26,(anonymous_11) +FNDA:26,(anonymous_12) +FNDA:2,(anonymous_13) +FNDA:26,(anonymous_14) +FNDA:2,(anonymous_15) +FNDA:2,publicKey_2 +FNDA:51,cleanup +DA:10,2 +DA:12,1 +DA:15,1 +DA:26,5 +DA:27,5 +DA:28,5 +DA:29,5 +DA:36,52 +DA:37,52 +DA:40,52 +DA:41,52 +DA:42,52 +DA:43,52 +DA:47,52 +DA:48,21 +DA:50,31 +DA:54,52 +DA:55,51 +DA:57,1 +DA:61,3 +DA:68,11 +DA:69,9 +DA:70,5 +DA:71,5 +DA:72,5 +DA:74,7 +DA:75,2 +DA:76,2 +DA:77,2 +DA:81,9 +DA:82,2 +DA:83,2 +DA:84,2 +DA:87,7 +DA:95,8 +DA:96,1 +DA:98,7 +DA:99,3 +DA:101,4 +DA:102,2 +DA:105,2 +DA:106,2 +DA:111,2 +DA:120,5 +DA:121,1 +DA:123,4 +DA:124,3 +DA:127,1 +DA:128,1 +DA:129,1 +DA:133,32 +DA:134,2 +DA:136,30 +DA:137,4 +DA:140,26 +DA:141,26 +DA:143,26 +DA:144,26 +DA:145,26 +DA:146,26 +DA:147,26 +DA:150,26 +DA:151,2 +DA:152,2 +DA:153,2 +DA:156,26 +DA:158,26 +DA:160,26 +DA:161,25 +DA:162,25 +DA:163,22 +DA:164,1 +DA:167,21 +DA:168,21 +DA:171,2 +DA:172,2 +DA:178,2 +DA:179,1 +DA:181,1 +DA:185,51 +DA:186,51 +DA:187,1 +DA:189,51 +LF:83 +LH:83 +BRDA:39,0,0,52 +BRDA:47,1,0,21 +BRDA:47,1,1,31 +BRDA:47,2,0,52 +BRDA:47,2,1,33 +BRDA:47,2,2,32 +BRDA:54,3,0,51 +BRDA:54,3,1,1 +BRDA:54,4,0,52 +BRDA:54,4,1,3 +BRDA:54,4,2,3 +BRDA:54,4,3,2 +BRDA:68,5,0,9 +BRDA:68,5,1,2 +BRDA:69,6,0,5 +BRDA:69,6,1,4 +BRDA:69,7,0,9 +BRDA:69,7,1,5 +BRDA:71,8,0,2 +BRDA:71,8,1,3 +BRDA:71,9,0,3 +BRDA:71,9,1,0 +BRDA:72,10,0,2 +BRDA:72,10,1,3 +BRDA:72,11,0,5 +BRDA:72,11,1,4 +BRDA:72,11,2,3 +BRDA:74,12,0,2 +BRDA:74,12,1,5 +BRDA:74,13,0,7 +BRDA:74,13,1,2 +BRDA:76,14,0,2 +BRDA:76,14,1,0 +BRDA:76,15,0,0 +BRDA:76,15,1,0 +BRDA:77,16,0,0 +BRDA:77,16,1,2 +BRDA:77,17,0,2 +BRDA:77,17,1,2 +BRDA:77,17,2,1 +BRDA:81,18,0,2 +BRDA:81,18,1,7 +BRDA:81,19,0,9 +BRDA:81,19,1,2 +BRDA:83,20,0,2 +BRDA:83,20,1,0 +BRDA:83,21,0,0 +BRDA:83,21,1,0 +BRDA:84,22,0,2 +BRDA:84,22,1,0 +BRDA:84,23,0,2 +BRDA:84,23,1,2 +BRDA:95,24,0,1 +BRDA:95,24,1,7 +BRDA:98,25,0,3 +BRDA:98,25,1,4 +BRDA:98,26,0,7 +BRDA:98,26,1,6 +BRDA:98,26,2,5 +BRDA:101,27,0,2 +BRDA:101,27,1,2 +BRDA:101,28,0,4 +BRDA:101,28,1,3 +BRDA:101,28,2,2 +BRDA:120,29,0,1 +BRDA:120,29,1,4 +BRDA:123,30,0,3 +BRDA:123,30,1,1 +BRDA:123,31,0,4 +BRDA:123,31,1,2 +BRDA:133,32,0,2 +BRDA:133,32,1,30 +BRDA:136,33,0,4 +BRDA:136,33,1,26 +BRDA:136,34,0,30 +BRDA:136,34,1,28 +BRDA:144,35,0,0 +BRDA:144,35,1,26 +BRDA:160,36,0,1 +BRDA:160,36,1,25 +BRDA:160,37,0,26 +BRDA:160,37,1,25 +BRDA:161,38,0,25 +BRDA:161,38,1,0 +BRDA:163,39,0,1 +BRDA:163,39,1,21 +BRDA:163,40,0,22 +BRDA:163,40,1,21 +BRDA:178,41,0,1 +BRDA:178,41,1,1 +BRF:90 +BRH:79 +end_of_record +TN: +SF:src/soroban.ts +FN:44,getServer +FN:56,clearServerCache +FN:68,normalizePollingOptions +FN:81,buildContractCallTx +FN:113,invokeContract +FN:174,simulateReadOnly +FN:203,getTokenDecimals +FN:215,scValToI128 +FN:224,scValToU64 +FN:229,u64ToScVal +FN:234,boolToScVal +FN:238,sleep +FN:239,(anonymous_12) +FNF:13 +FNH:13 +FNDA:41,getServer +FNDA:12,clearServerCache +FNDA:9,normalizePollingOptions +FNDA:11,buildContractCallTx +FNDA:9,invokeContract +FNDA:6,simulateReadOnly +FNDA:3,getTokenDecimals +FNDA:20,scValToI128 +FNDA:12,scValToU64 +FNDA:5,u64ToScVal +FNDA:9,boolToScVal +FNDA:7,sleep +FNDA:7,(anonymous_12) +DA:24,18 +DA:30,18 +DA:42,18 +DA:45,41 +DA:46,41 +DA:47,15 +DA:48,15 +DA:50,41 +DA:57,12 +DA:65,18 +DA:66,18 +DA:69,9 +DA:89,11 +DA:92,11 +DA:93,11 +DA:95,2 +DA:98,9 +DA:100,9 +DA:120,9 +DA:121,9 +DA:125,9 +DA:126,9 +DA:128,1 +DA:130,8 +DA:131,1 +DA:135,7 +DA:138,7 +DA:142,7 +DA:143,7 +DA:145,1 +DA:147,6 +DA:148,1 +DA:152,5 +DA:153,5 +DA:154,7 +DA:156,7 +DA:157,7 +DA:159,1 +DA:161,6 +DA:162,1 +DA:164,5 +DA:165,1 +DA:168,2 +DA:179,6 +DA:182,6 +DA:183,6 +DA:185,2 +DA:188,4 +DA:189,1 +DA:191,3 +DA:192,1 +DA:194,2 +DA:209,3 +DA:210,1 +DA:211,1 +DA:216,20 +DA:217,20 +DA:218,20 +DA:220,20 +DA:225,12 +DA:230,5 +DA:235,9 +DA:239,7 +LF:63 +LH:63 +BRDA:46,0,0,15 +BRDA:46,0,1,26 +BRDA:68,1,0,9 +BRDA:70,2,0,9 +BRDA:70,2,1,6 +BRDA:71,3,0,9 +BRDA:71,3,1,6 +BRDA:95,4,0,2 +BRDA:95,4,1,2 +BRDA:118,5,0,9 +BRDA:128,6,0,1 +BRDA:128,6,1,1 +BRDA:130,7,0,1 +BRDA:130,7,1,7 +BRDA:145,8,0,1 +BRDA:145,8,1,1 +BRDA:147,9,0,1 +BRDA:147,9,1,5 +BRDA:159,10,0,1 +BRDA:159,10,1,1 +BRDA:161,11,0,1 +BRDA:161,11,1,5 +BRDA:164,12,0,1 +BRDA:164,12,1,4 +BRDA:185,13,0,2 +BRDA:185,13,1,1 +BRDA:188,14,0,1 +BRDA:188,14,1,3 +BRDA:191,15,0,1 +BRDA:191,15,1,2 +BRF:30 +BRH:30 +end_of_record +TN: +SF:src/streams.ts +FN:53,warnV1Deprecated +FN:76,constructor +FN:92,setWallet +FN:96,_signer +FN:100,_signerPublicKey +FN:115,_resolveCallerAddress +FN:139,create +FN:213,get +FN:223,withdrawable +FN:233,withdraw +FN:253,batchWithdraw +FN:257,(anonymous_11) +FN:260,(anonymous_12) +FN:275,cancel +FN:281,pause +FN:287,resume +FN:293,topUp +FN:304,clawback +FN:331,list +FN:352,(anonymous_19) +FN:357,(anonymous_20) +FN:359,(anonymous_21) +FN:385,subscribeAsync +FN:396,subscribe +FN:401,(anonymous_24) +FN:402,(anonymous_25) +FN:409,(anonymous_26) +FN:423,_ensureCanMutate +FN:429,_getSenderAddress +FN:442,_signTx +FN:469,_server +FN:478,_resolveAddr +FN:484,_simulateTx +FN:495,_invoke +FN:509,_sendAndPoll +FN:546,parseStreamInfo +FN:568,(anonymous_36) +FN:572,sleep +FN:573,(anonymous_38) +FNF:39 +FNH:38 +FNDA:17,warnV1Deprecated +FNDA:102,constructor +FNDA:4,setWallet +FNDA:1,_signer +FNDA:105,_signerPublicKey +FNDA:40,_resolveCallerAddress +FNDA:17,create +FNDA:34,get +FNDA:1,withdrawable +FNDA:2,withdraw +FNDA:6,batchWithdraw +FNDA:10,(anonymous_11) +FNDA:10,(anonymous_12) +FNDA:2,cancel +FNDA:4,pause +FNDA:2,resume +FNDA:2,topUp +FNDA:3,clawback +FNDA:17,list +FNDA:2,(anonymous_19) +FNDA:13,(anonymous_20) +FNDA:32,(anonymous_21) +FNDA:6,subscribeAsync +FNDA:5,subscribe +FNDA:4,(anonymous_24) +FNDA:1,(anonymous_25) +FNDA:5,(anonymous_26) +FNDA:24,_ensureCanMutate +FNDA:28,_getSenderAddress +FNDA:20,_signTx +FNDA:50,_server +FNDA:44,_resolveAddr +FNDA:34,_simulateTx +FNDA:7,_invoke +FNDA:18,_sendAndPoll +FNDA:32,parseStreamInfo +FNDA:0,(anonymous_36) +FNDA:19,sleep +FNDA:19,(anonymous_38) +DA:42,22 +DA:55,17 +DA:58,17 +DA:59,16 +DA:60,4 +DA:61,4 +DA:76,102 +DA:77,102 +DA:78,102 +DA:79,102 +DA:80,102 +DA:82,102 +DA:83,8 +DA:84,94 +DA:85,46 +DA:93,4 +DA:97,1 +DA:101,105 +DA:102,2 +DA:103,2 +DA:105,103 +DA:106,95 +DA:107,46 +DA:116,40 +DA:117,10 +DA:118,10 +DA:120,31 +DA:121,30 +DA:122,29 +DA:140,17 +DA:141,17 +DA:146,16 +DA:149,16 +DA:150,1 +DA:152,15 +DA:153,1 +DA:155,14 +DA:156,1 +DA:158,13 +DA:159,1 +DA:161,12 +DA:162,1 +DA:164,11 +DA:165,2 +DA:168,9 +DA:171,17 +DA:173,7 +DA:174,7 +DA:177,17 +DA:178,17 +DA:180,17 +DA:191,17 +DA:192,7 +DA:193,7 +DA:195,7 +DA:196,1 +DA:199,6 +DA:200,6 +DA:201,6 +DA:203,3 +DA:204,1 +DA:206,2 +DA:208,2 +DA:209,17 +DA:214,34 +DA:215,34 +DA:216,33 +DA:217,33 +DA:218,33 +DA:219,32 +DA:224,1 +DA:225,1 +DA:226,1 +DA:227,1 +DA:228,1 +DA:229,1 +DA:234,2 +DA:235,2 +DA:236,2 +DA:237,0 +DA:254,6 +DA:256,6 +DA:257,10 +DA:260,5 +DA:261,10 +DA:262,10 +DA:263,8 +DA:265,2 +DA:266,2 +DA:276,2 +DA:277,2 +DA:282,4 +DA:283,4 +DA:288,2 +DA:289,2 +DA:294,2 +DA:295,2 +DA:305,3 +DA:306,3 +DA:307,2 +DA:308,2 +DA:309,2 +DA:310,2 +DA:312,2 +DA:313,1 +DA:316,1 +DA:317,1 +DA:318,1 +DA:320,1 +DA:321,0 +DA:323,1 +DA:332,17 +DA:333,17 +DA:339,17 +DA:341,5 +DA:342,5 +DA:344,0 +DA:346,5 +DA:347,3 +DA:349,2 +DA:352,14 +DA:353,2 +DA:355,14 +DA:357,14 +DA:358,13 +DA:359,32 +DA:360,13 +DA:361,13 +DA:362,13 +DA:374,14 +DA:375,10 +DA:376,4 +DA:377,3 +DA:381,1 +DA:389,6 +DA:390,6 +DA:391,4 +DA:392,4 +DA:397,5 +DA:398,5 +DA:400,5 +DA:401,4 +DA:403,1 +DA:404,1 +DA:405,1 +DA:408,5 +DA:410,5 +DA:411,5 +DA:412,1 +DA:413,1 +DA:416,5 +DA:424,24 +DA:425,8 +DA:430,28 +DA:431,25 +DA:433,3 +DA:434,1 +DA:436,2 +DA:437,1 +DA:439,1 +DA:443,20 +DA:444,16 +DA:447,16 +DA:448,2 +DA:450,14 +DA:451,0 +DA:453,14 +DA:455,4 +DA:456,3 +DA:457,3 +DA:458,1 +DA:460,3 +DA:462,1 +DA:463,1 +DA:464,1 +DA:466,0 +DA:470,50 +DA:471,25 +DA:475,50 +DA:479,44 +DA:480,44 +DA:481,43 +DA:485,34 +DA:486,34 +DA:487,34 +DA:488,1 +DA:490,33 +DA:491,33 +DA:496,7 +DA:497,7 +DA:498,7 +DA:499,7 +DA:500,7 +DA:501,0 +DA:503,7 +DA:504,7 +DA:505,7 +DA:506,5 +DA:514,18 +DA:515,18 +DA:517,1 +DA:519,17 +DA:520,2 +DA:522,15 +DA:523,15 +DA:524,18 +DA:525,18 +DA:526,19 +DA:528,19 +DA:529,19 +DA:531,1 +DA:533,18 +DA:534,10 +DA:536,8 +DA:537,2 +DA:540,2 +DA:547,32 +DA:548,32 +DA:549,32 +DA:550,0 +DA:551,0 +DA:553,32 +DA:568,32 +DA:569,32 +DA:573,19 +LF:225 +LH:217 +BRDA:55,0,0,17 +BRDA:55,0,1,17 +BRDA:55,0,2,17 +BRDA:58,1,0,1 +BRDA:58,1,1,16 +BRDA:59,2,0,12 +BRDA:59,2,1,4 +BRDA:77,3,0,102 +BRDA:77,3,1,89 +BRDA:82,4,0,8 +BRDA:82,4,1,94 +BRDA:84,5,0,46 +BRDA:84,5,1,48 +BRDA:97,6,0,1 +BRDA:97,6,1,1 +BRDA:101,7,0,2 +BRDA:101,7,1,103 +BRDA:103,8,0,2 +BRDA:103,8,1,0 +BRDA:105,9,0,8 +BRDA:105,9,1,95 +BRDA:106,10,0,49 +BRDA:106,10,1,46 +BRDA:116,11,0,10 +BRDA:116,11,1,30 +BRDA:118,12,0,9 +BRDA:118,12,1,1 +BRDA:120,13,0,1 +BRDA:120,13,1,30 +BRDA:121,14,0,1 +BRDA:121,14,1,29 +BRDA:145,15,0,16 +BRDA:149,16,0,1 +BRDA:149,16,1,15 +BRDA:149,17,0,16 +BRDA:149,17,1,15 +BRDA:149,17,2,15 +BRDA:152,18,0,1 +BRDA:152,18,1,14 +BRDA:152,19,0,15 +BRDA:152,19,1,14 +BRDA:152,19,2,14 +BRDA:155,20,0,1 +BRDA:155,20,1,13 +BRDA:155,21,0,14 +BRDA:155,21,1,13 +BRDA:155,21,2,13 +BRDA:158,22,0,1 +BRDA:158,22,1,12 +BRDA:158,23,0,13 +BRDA:158,23,1,10 +BRDA:158,23,2,10 +BRDA:161,24,0,1 +BRDA:161,24,1,11 +BRDA:161,25,0,12 +BRDA:161,25,1,1 +BRDA:161,25,2,1 +BRDA:164,26,0,2 +BRDA:164,26,1,9 +BRDA:164,27,0,11 +BRDA:164,27,1,2 +BRDA:168,28,0,9 +BRDA:168,28,1,0 +BRDA:174,29,0,0 +BRDA:174,29,1,7 +BRDA:177,30,0,17 +BRDA:177,30,1,7 +BRDA:178,31,0,7 +BRDA:178,31,1,0 +BRDA:195,32,0,1 +BRDA:195,32,1,6 +BRDA:203,33,0,1 +BRDA:203,33,1,2 +BRDA:208,34,0,2 +BRDA:208,34,1,0 +BRDA:236,35,0,2 +BRDA:236,35,1,0 +BRDA:262,36,0,8 +BRDA:262,36,1,2 +BRDA:269,37,0,2 +BRDA:269,37,1,0 +BRDA:312,38,0,1 +BRDA:312,38,1,1 +BRDA:320,39,0,0 +BRDA:320,39,1,1 +BRDA:332,40,0,17 +BRDA:333,41,0,17 +BRDA:333,41,1,12 +BRDA:339,42,0,5 +BRDA:339,42,1,12 +BRDA:346,43,0,3 +BRDA:346,43,1,2 +BRDA:368,44,0,2 +BRDA:368,44,1,11 +BRDA:374,45,0,10 +BRDA:374,45,1,4 +BRDA:376,46,0,3 +BRDA:376,46,1,1 +BRDA:390,47,0,2 +BRDA:390,47,1,4 +BRDA:401,48,0,1 +BRDA:401,48,1,3 +BRDA:403,49,0,1 +BRDA:403,49,1,0 +BRDA:411,50,0,1 +BRDA:411,50,1,4 +BRDA:424,51,0,8 +BRDA:424,51,1,16 +BRDA:424,52,0,24 +BRDA:424,52,1,9 +BRDA:424,52,2,8 +BRDA:430,53,0,25 +BRDA:430,53,1,3 +BRDA:433,54,0,1 +BRDA:433,54,1,2 +BRDA:436,55,0,1 +BRDA:436,55,1,1 +BRDA:443,56,0,16 +BRDA:443,56,1,4 +BRDA:447,57,0,2 +BRDA:447,57,1,14 +BRDA:450,58,0,0 +BRDA:450,58,1,14 +BRDA:455,59,0,3 +BRDA:455,59,1,1 +BRDA:457,60,0,1 +BRDA:457,60,1,2 +BRDA:462,61,0,1 +BRDA:462,61,1,0 +BRDA:470,62,0,25 +BRDA:470,62,1,25 +BRDA:480,63,0,1 +BRDA:480,63,1,43 +BRDA:487,64,0,1 +BRDA:487,64,1,33 +BRDA:490,65,0,0 +BRDA:490,65,1,33 +BRDA:500,66,0,0 +BRDA:500,66,1,7 +BRDA:517,67,0,1 +BRDA:517,67,1,0 +BRDA:519,68,0,2 +BRDA:519,68,1,15 +BRDA:523,69,0,15 +BRDA:523,69,1,13 +BRDA:524,70,0,18 +BRDA:524,70,1,13 +BRDA:531,71,0,1 +BRDA:531,71,1,0 +BRDA:533,72,0,10 +BRDA:533,72,1,8 +BRDA:536,73,0,2 +BRDA:536,73,1,6 +BRDA:547,74,0,32 +BRDA:547,74,1,0 +BRDA:550,75,0,0 +BRDA:550,75,1,0 +BRDA:550,75,2,0 +BRDA:556,76,0,0 +BRDA:556,76,1,32 +BRDA:557,77,0,0 +BRDA:557,77,1,32 +BRDA:558,78,0,0 +BRDA:558,78,1,32 +BRDA:559,79,0,0 +BRDA:559,79,1,32 +BRDA:560,80,0,0 +BRDA:560,80,1,32 +BRDA:561,81,0,0 +BRDA:561,81,1,32 +BRDA:562,82,0,0 +BRDA:562,82,1,32 +BRDA:563,83,0,32 +BRDA:563,83,1,32 +BRDA:564,84,0,0 +BRDA:564,84,1,32 +BRDA:565,85,0,32 +BRDA:565,85,1,32 +BRDA:566,86,0,32 +BRDA:566,86,1,32 +BRF:180 +BRH:153 +end_of_record +TN: +SF:src/utils.ts +FN:6,_pow10 +FN:20,toStroops +FN:40,fromStroops +FN:57,calculateRate +FN:72,streamProgress +FN:84,withdrawableLocal +FN:116,bigintSafeStringify +FN:120,_safeStringify +FN:157,isValidAddress +FNF:9 +FNH:9 +FNDA:41,_pow10 +FNDA:34,toStroops +FNDA:7,fromStroops +FNDA:14,calculateRate +FNDA:4,streamProgress +FNDA:6,withdrawableLocal +FNDA:573,bigintSafeStringify +FNDA:2145,_safeStringify +FNDA:8,isValidAddress +DA:4,29 +DA:7,41 +DA:8,41 +DA:9,3 +DA:10,3 +DA:12,41 +DA:21,34 +DA:22,34 +DA:23,20 +DA:25,14 +DA:26,34 +DA:27,34 +DA:29,34 +DA:30,34 +DA:31,4 +DA:32,4 +DA:33,3 +DA:36,14 +DA:41,7 +DA:42,7 +DA:43,7 +DA:44,7 +DA:45,26 +DA:46,7 +DA:47,7 +DA:58,14 +DA:59,14 +DA:60,14 +DA:61,13 +DA:62,13 +DA:64,13 +DA:73,4 +DA:74,4 +DA:75,3 +DA:76,2 +DA:77,1 +DA:85,6 +DA:87,5 +DA:93,6 +DA:95,4 +DA:96,4 +DA:98,4 +DA:99,4 +DA:100,4 +DA:117,573 +DA:121,2145 +DA:123,299 +DA:125,1846 +DA:126,685 +DA:127,685 +DA:129,685 +DA:130,2 +DA:131,2 +DA:132,2 +DA:133,8 +DA:135,2 +DA:138,683 +DA:139,683 +DA:140,683 +DA:141,683 +DA:142,1564 +DA:143,1564 +DA:145,683 +DA:158,8 +DA:159,3 +DA:161,5 +LF:66 +LH:66 +BRDA:8,0,0,3 +BRDA:8,0,1,38 +BRDA:20,1,0,34 +BRDA:22,2,0,20 +BRDA:22,2,1,14 +BRDA:25,3,0,14 +BRDA:25,3,1,0 +BRDA:30,4,0,4 +BRDA:30,4,1,30 +BRDA:32,5,0,3 +BRDA:32,5,1,1 +BRDA:40,6,0,7 +BRDA:45,7,0,7 +BRDA:45,7,1,31 +BRDA:46,8,0,2 +BRDA:46,8,1,5 +BRDA:57,9,0,14 +BRDA:60,10,0,1 +BRDA:60,10,1,13 +BRDA:64,11,0,7 +BRDA:64,11,1,6 +BRDA:72,12,0,4 +BRDA:74,13,0,1 +BRDA:74,13,1,3 +BRDA:75,14,0,1 +BRDA:75,14,1,2 +BRDA:76,15,0,1 +BRDA:76,15,1,1 +BRDA:84,16,0,6 +BRDA:85,17,0,1 +BRDA:85,17,1,5 +BRDA:87,18,0,1 +BRDA:87,18,1,4 +BRDA:89,19,0,1 +BRDA:89,19,1,3 +BRDA:89,20,0,4 +BRDA:89,20,1,4 +BRDA:93,21,0,1 +BRDA:93,21,1,5 +BRDA:96,22,0,0 +BRDA:96,22,1,4 +BRDA:100,23,0,4 +BRDA:100,23,1,0 +BRDA:121,24,0,299 +BRDA:121,24,1,1846 +BRDA:125,25,0,1161 +BRDA:125,25,1,685 +BRDA:125,26,0,1846 +BRDA:125,26,1,1843 +BRDA:126,27,0,0 +BRDA:126,27,1,685 +BRDA:129,28,0,2 +BRDA:129,28,1,683 +BRDA:158,29,0,3 +BRDA:158,29,1,5 +BRDA:158,30,0,8 +BRDA:158,30,1,6 +BRF:57 +BRH:53 +end_of_record +TN: +SF:src/adapters/index.ts +FNF:0 +FNH:0 +LF:0 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/adapters/keypair.ts +FN:9,constructor +FN:11,getPublicKey +FN:15,signTransaction +FN:35,isConnected +FNF:4 +FNH:4 +FNDA:50,constructor +FNDA:34,getPublicKey +FNDA:17,signTransaction +FNDA:1,isConnected +DA:9,50 +DA:12,34 +DA:19,17 +DA:20,2 +DA:21,1 +DA:26,1 +DA:27,1 +DA:28,1 +DA:31,15 +DA:32,15 +DA:36,1 +LF:11 +LH:11 +BRDA:19,0,0,2 +BRDA:19,0,1,15 +BRDA:20,1,0,1 +BRDA:20,1,1,1 +BRF:4 +BRH:4 +end_of_record +TN: +SF:src/adapters/types.ts +FNF:0 +FNH:0 +LF:0 +LH:0 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/adapters/walletconnect.ts +FN:69,constructor +FN:94,setSession +FN:101,isConnected +FN:108,getPublicKey +FN:119,connect +FN:161,_withTimeout +FN:163,(anonymous_6) +FN:164,(anonymous_7) +FN:169,(anonymous_8) +FN:175,disconnect +FN:195,signTransaction +FN:247,getPublicKeyFromSession +FNF:12 +FNH:11 +FNDA:51,constructor +FNDA:0,setSession +FNDA:26,isConnected +FNDA:11,getPublicKey +FNDA:6,connect +FNDA:10,_withTimeout +FNDA:10,(anonymous_6) +FNDA:5,(anonymous_7) +FNDA:10,(anonymous_8) +FNDA:5,disconnect +FNDA:1,signTransaction +FNDA:28,getPublicKeyFromSession +DA:51,7 +DA:70,51 +DA:71,51 +DA:76,51 +DA:77,6 +DA:78,6 +DA:84,45 +DA:85,45 +DA:86,45 +DA:87,51 +DA:88,51 +DA:95,0 +DA:102,26 +DA:109,11 +DA:110,11 +DA:111,3 +DA:113,8 +DA:120,6 +DA:121,1 +DA:124,5 +DA:125,5 +DA:138,2 +DA:139,1 +DA:140,1 +DA:141,1 +DA:148,1 +DA:149,1 +DA:150,0 +DA:153,1 +DA:163,10 +DA:164,10 +DA:165,5 +DA:169,10 +DA:176,5 +DA:177,5 +DA:178,5 +DA:179,4 +DA:188,5 +DA:199,1 +DA:200,1 +DA:201,1 +DA:203,1 +DA:204,0 +DA:207,1 +DA:208,1 +DA:209,0 +DA:213,1 +DA:226,1 +DA:227,1 +DA:231,1 +DA:232,0 +DA:235,1 +DA:236,0 +DA:239,1 +DA:248,28 +DA:251,26 +DA:252,8 +DA:258,18 +DA:259,18 +DA:260,8 +DA:261,8 +DA:262,7 +DA:267,11 +DA:268,5 +DA:269,5 +DA:272,6 +LF:66 +LH:60 +BRDA:69,0,0,51 +BRDA:71,1,0,51 +BRDA:71,1,1,32 +BRDA:76,2,0,6 +BRDA:76,2,1,45 +BRDA:86,3,0,45 +BRDA:86,3,1,32 +BRDA:87,4,0,51 +BRDA:87,4,1,23 +BRDA:88,5,0,51 +BRDA:88,5,1,40 +BRDA:95,6,0,0 +BRDA:95,6,1,0 +BRDA:102,7,0,26 +BRDA:102,7,1,16 +BRDA:110,8,0,3 +BRDA:110,8,1,8 +BRDA:120,9,0,1 +BRDA:120,9,1,5 +BRDA:124,10,0,5 +BRDA:124,10,1,0 +BRDA:124,11,0,5 +BRDA:124,11,1,5 +BRDA:138,12,0,1 +BRDA:138,12,1,1 +BRDA:140,13,0,1 +BRDA:140,13,1,0 +BRDA:149,14,0,0 +BRDA:149,14,1,1 +BRDA:178,15,0,4 +BRDA:178,15,1,1 +BRDA:178,16,0,5 +BRDA:178,16,1,5 +BRDA:178,16,2,5 +BRDA:200,17,0,0 +BRDA:200,17,1,1 +BRDA:203,18,0,0 +BRDA:203,18,1,1 +BRDA:203,19,0,1 +BRDA:203,19,1,1 +BRDA:208,20,0,0 +BRDA:208,20,1,1 +BRDA:220,21,0,1 +BRDA:220,21,1,1 +BRDA:227,22,0,1 +BRDA:227,22,1,0 +BRDA:229,23,0,0 +BRDA:229,23,1,0 +BRDA:229,23,2,0 +BRDA:231,24,0,0 +BRDA:231,24,1,1 +BRDA:235,25,0,0 +BRDA:235,25,1,1 +BRDA:239,26,0,1 +BRDA:239,26,1,0 +BRDA:248,27,0,2 +BRDA:248,27,1,26 +BRDA:251,28,0,8 +BRDA:251,28,1,18 +BRDA:252,29,0,0 +BRDA:252,29,1,8 +BRDA:253,30,0,0 +BRDA:253,30,1,0 +BRDA:259,31,0,8 +BRDA:259,31,1,10 +BRDA:259,32,0,18 +BRDA:259,32,1,9 +BRDA:259,32,2,8 +BRDA:261,33,0,7 +BRDA:261,33,1,1 +BRDA:262,34,0,7 +BRDA:262,34,1,0 +BRDA:262,35,0,7 +BRDA:262,35,1,0 +BRDA:267,36,0,5 +BRDA:267,36,1,6 +BRDA:267,37,0,11 +BRDA:267,37,1,6 +BRDA:269,38,0,5 +BRDA:269,38,1,0 +BRDA:269,39,0,5 +BRDA:269,39,1,0 +BRF:82 +BRH:60 +end_of_record +TN: +SF:src/dashboard/transaction-history.ts +FN:136,createInitialTransactionHistoryState +FN:190,asString +FN:197,asTimestamp +FN:215,asEnum +FN:226,toErrorMessage +FN:248,normalizeTransaction +FN:282,normalizeTransactions +FN:295,(anonymous_7) +FN:325,clampPage +FN:339,transactionHistoryReducer +FN:442,selectFilteredTransactions +FN:449,(anonymous_11) +FN:458,(anonymous_12) +FN:465,selectVisibleTransactions +FN:469,(anonymous_14) +FN:480,selectTotalPages +FN:493,selectViewStatus +FN:510,formatAddress +FN:519,formatAmount +FN:540,formatTimestamp +FNF:20 +FNH:20 +FNDA:23,createInitialTransactionHistoryState +FNDA:262,asString +FNDA:34,asTimestamp +FNDA:83,asEnum +FNDA:9,toErrorMessage +FNDA:38,normalizeTransaction +FNDA:24,normalizeTransactions +FNDA:1,(anonymous_7) +FNDA:36,clampPage +FNDA:41,transactionHistoryReducer +FNDA:45,selectFilteredTransactions +FNDA:42,(anonymous_11) +FNDA:27,(anonymous_12) +FNDA:22,selectVisibleTransactions +FNDA:8,(anonymous_14) +FNDA:3,selectTotalPages +FNDA:12,selectViewStatus +FNDA:8,formatAddress +FNDA:12,formatAmount +FNDA:7,formatTimestamp +DA:119,1 +DA:122,1 +DA:139,23 +DA:149,23 +DA:159,23 +DA:162,1 +DA:170,1 +DA:180,1 +DA:191,262 +DA:192,16 +DA:193,15 +DA:194,14 +DA:198,34 +DA:200,29 +DA:202,5 +DA:203,3 +DA:204,3 +DA:205,3 +DA:206,3 +DA:208,4 +DA:209,0 +DA:210,0 +DA:212,4 +DA:220,83 +DA:221,80 +DA:222,80 +DA:230,9 +DA:231,7 +DA:232,4 +DA:233,2 +DA:234,2 +DA:236,3 +DA:249,38 +DA:251,30 +DA:253,30 +DA:254,38 +DA:258,38 +DA:260,27 +DA:262,38 +DA:283,24 +DA:285,24 +DA:286,11 +DA:287,11 +DA:296,1 +DA:303,24 +DA:305,15 +DA:306,15 +DA:308,15 +DA:309,27 +DA:310,27 +DA:313,19 +DA:314,17 +DA:315,17 +DA:318,15 +DA:326,36 +DA:327,36 +DA:328,34 +DA:329,34 +DA:330,33 +DA:331,33 +DA:343,41 +DA:345,41 +DA:346,2 +DA:349,39 +DA:353,5 +DA:356,9 +DA:357,9 +DA:361,9 +DA:377,4 +DA:384,5 +DA:385,5 +DA:387,5 +DA:388,2 +DA:390,5 +DA:391,0 +DA:393,5 +DA:394,0 +DA:396,5 +DA:397,2 +DA:400,5 +DA:409,5 +DA:410,5 +DA:414,5 +DA:419,8 +DA:423,8 +DA:426,8 +DA:430,2 +DA:433,1 +DA:445,45 +DA:446,45 +DA:447,45 +DA:449,45 +DA:450,42 +DA:451,42 +DA:452,38 +DA:453,38 +DA:454,0 +DA:456,38 +DA:458,9 +DA:459,27 +DA:468,22 +DA:469,22 +DA:471,22 +DA:472,22 +DA:473,22 +DA:474,22 +DA:476,22 +DA:483,3 +DA:484,3 +DA:485,3 +DA:496,12 +DA:497,12 +DA:499,12 +DA:500,10 +DA:501,7 +DA:502,3 +DA:511,8 +DA:512,8 +DA:513,5 +DA:514,5 +DA:515,4 +DA:520,12 +DA:521,12 +DA:522,12 +DA:523,12 +DA:525,10 +DA:526,10 +DA:527,10 +DA:528,12 +DA:529,12 +DA:531,12 +DA:532,12 +DA:534,12 +DA:541,7 +DA:542,7 +DA:543,4 +DA:544,4 +DA:545,4 +LF:138 +LH:133 +BRDA:137,0,0,23 +BRDA:153,1,0,0 +BRDA:153,1,1,23 +BRDA:156,2,0,23 +BRDA:156,2,1,23 +BRDA:190,3,0,262 +BRDA:191,4,0,246 +BRDA:191,4,1,16 +BRDA:192,5,0,1 +BRDA:192,5,1,15 +BRDA:192,6,0,16 +BRDA:192,6,1,1 +BRDA:193,7,0,1 +BRDA:193,7,1,14 +BRDA:198,8,0,29 +BRDA:198,8,1,5 +BRDA:198,9,0,34 +BRDA:198,9,1,29 +BRDA:200,10,0,6 +BRDA:200,10,1,23 +BRDA:202,11,0,3 +BRDA:202,11,1,2 +BRDA:202,12,0,5 +BRDA:202,12,1,3 +BRDA:204,13,0,0 +BRDA:204,13,1,3 +BRDA:206,14,0,1 +BRDA:206,14,1,2 +BRDA:208,15,0,0 +BRDA:208,15,1,4 +BRDA:210,16,0,0 +BRDA:210,16,1,0 +BRDA:220,17,0,3 +BRDA:220,17,1,80 +BRDA:222,18,0,77 +BRDA:222,18,1,3 +BRDA:228,19,0,9 +BRDA:230,20,0,2 +BRDA:230,20,1,7 +BRDA:230,21,0,9 +BRDA:230,21,1,2 +BRDA:231,22,0,3 +BRDA:231,22,1,4 +BRDA:231,23,0,7 +BRDA:231,23,1,4 +BRDA:232,24,0,2 +BRDA:232,24,1,2 +BRDA:232,25,0,4 +BRDA:232,25,1,2 +BRDA:234,26,0,1 +BRDA:234,26,1,1 +BRDA:234,27,0,2 +BRDA:234,27,1,2 +BRDA:249,28,0,8 +BRDA:249,28,1,30 +BRDA:249,29,0,38 +BRDA:249,29,1,35 +BRDA:249,29,2,32 +BRDA:253,30,0,30 +BRDA:253,30,1,3 +BRDA:253,30,2,3 +BRDA:254,31,0,38 +BRDA:254,31,1,4 +BRDA:254,31,2,4 +BRDA:258,32,0,3 +BRDA:258,32,1,35 +BRDA:258,33,0,38 +BRDA:258,33,1,3 +BRDA:260,34,0,27 +BRDA:260,34,1,1 +BRDA:260,34,2,1 +BRDA:263,35,0,0 +BRDA:263,35,1,27 +BRDA:265,36,0,38 +BRDA:265,36,1,1 +BRDA:266,37,0,38 +BRDA:266,37,1,1 +BRDA:270,38,0,38 +BRDA:270,38,1,1 +BRDA:270,38,2,1 +BRDA:272,39,0,38 +BRDA:272,39,1,1 +BRDA:272,39,2,1 +BRDA:274,40,0,38 +BRDA:274,40,1,1 +BRDA:285,41,0,11 +BRDA:285,41,1,13 +BRDA:285,42,0,24 +BRDA:285,42,1,20 +BRDA:285,42,2,18 +BRDA:288,43,0,11 +BRDA:288,43,1,6 +BRDA:288,43,2,5 +BRDA:288,43,3,5 +BRDA:288,43,4,4 +BRDA:288,43,5,3 +BRDA:294,44,0,1 +BRDA:294,44,1,2 +BRDA:296,45,0,1 +BRDA:296,45,1,0 +BRDA:296,46,0,1 +BRDA:296,46,1,1 +BRDA:303,47,0,9 +BRDA:303,47,1,15 +BRDA:310,48,0,8 +BRDA:310,48,1,19 +BRDA:313,49,0,2 +BRDA:313,49,1,17 +BRDA:326,50,0,35 +BRDA:326,50,1,1 +BRDA:327,51,0,2 +BRDA:327,51,1,34 +BRDA:329,52,0,1 +BRDA:329,52,1,33 +BRDA:331,53,0,3 +BRDA:331,53,1,30 +BRDA:343,54,0,41 +BRDA:343,54,1,6 +BRDA:345,55,0,2 +BRDA:345,55,1,39 +BRDA:345,56,0,41 +BRDA:345,56,1,40 +BRDA:345,56,2,40 +BRDA:349,57,0,5 +BRDA:349,57,1,9 +BRDA:349,57,2,4 +BRDA:349,57,3,5 +BRDA:349,57,4,5 +BRDA:349,57,5,8 +BRDA:349,57,6,2 +BRDA:349,57,7,1 +BRDA:368,58,0,1 +BRDA:368,58,1,8 +BRDA:368,59,0,9 +BRDA:368,59,1,1 +BRDA:384,60,0,5 +BRDA:384,60,1,0 +BRDA:387,61,0,2 +BRDA:387,61,1,3 +BRDA:390,62,0,0 +BRDA:390,62,1,5 +BRDA:393,63,0,0 +BRDA:393,63,1,5 +BRDA:396,64,0,2 +BRDA:396,64,1,3 +BRDA:419,65,0,5 +BRDA:419,65,1,3 +BRDA:423,66,0,1 +BRDA:423,66,1,7 +BRDA:423,67,0,8 +BRDA:423,67,1,5 +BRDA:445,68,0,41 +BRDA:445,68,1,4 +BRDA:446,69,0,45 +BRDA:446,69,1,4 +BRDA:450,70,0,0 +BRDA:450,70,1,42 +BRDA:451,71,0,4 +BRDA:451,71,1,38 +BRDA:451,72,0,42 +BRDA:451,72,1,5 +BRDA:452,73,0,0 +BRDA:452,73,1,38 +BRDA:452,74,0,38 +BRDA:452,74,1,0 +BRDA:453,75,0,0 +BRDA:453,75,1,38 +BRDA:453,76,0,38 +BRDA:453,76,1,0 +BRDA:456,77,0,29 +BRDA:456,77,1,9 +BRDA:471,78,0,22 +BRDA:471,78,1,2 +BRDA:473,79,0,22 +BRDA:473,79,1,2 +BRDA:484,80,0,3 +BRDA:484,80,1,1 +BRDA:496,81,0,12 +BRDA:496,81,1,1 +BRDA:499,82,0,2 +BRDA:499,82,1,10 +BRDA:499,83,0,12 +BRDA:499,83,1,3 +BRDA:500,84,0,3 +BRDA:500,84,1,7 +BRDA:500,85,0,10 +BRDA:500,85,1,4 +BRDA:501,86,0,4 +BRDA:501,86,1,3 +BRDA:510,87,0,8 +BRDA:512,88,0,3 +BRDA:512,88,1,5 +BRDA:514,89,0,1 +BRDA:514,89,1,4 +BRDA:519,90,0,12 +BRDA:522,91,0,1 +BRDA:522,91,1,11 +BRDA:523,92,0,2 +BRDA:523,92,1,10 +BRDA:527,93,0,10 +BRDA:527,93,1,0 +BRDA:528,94,0,1 +BRDA:528,94,1,9 +BRDA:532,95,0,1 +BRDA:532,95,1,9 +BRDA:534,96,0,5 +BRDA:534,96,1,5 +BRDA:542,97,0,3 +BRDA:542,97,1,4 +BRDA:544,98,0,0 +BRDA:544,98,1,4 +BRF:211 +BRH:194 +end_of_record +TN: +SF:src/nonce/NonceManager.ts +FN:21,constructor +FN:39,toSafeBigInt +FN:50,nonceKey +FN:54,acquire +FN:64,(anonymous_4) +FN:69,nextNonce +FN:80,(anonymous_6) +FN:88,releaseLock +FN:98,current +FN:102,remaining +FN:106,acquired +FN:110,reset +FN:116,(anonymous_12) +FN:125,destroy +FN:132,isNonceValid +FN:144,acquireWithFallback +FN:151,(anonymous_16) +FN:152,(anonymous_17) +FN:168,safeAcquire +FN:180,(anonymous_19) +FNF:20 +FNH:19 +FNDA:27,constructor +FNDA:55,toSafeBigInt +FNDA:121,nonceKey +FNDA:124,acquire +FNDA:2,(anonymous_4) +FNDA:124,nextNonce +FNDA:125,(anonymous_6) +FNDA:128,releaseLock +FNDA:5,current +FNDA:3,remaining +FNDA:5,acquired +FNDA:1,reset +FNDA:0,(anonymous_12) +FNDA:25,destroy +FNDA:1,isNonceValid +FNDA:4,acquireWithFallback +FNDA:4,(anonymous_16) +FNDA:1,(anonymous_17) +FNDA:1,safeAcquire +FNDA:1,(anonymous_19) +DA:11,1 +DA:16,27 +DA:17,27 +DA:18,27 +DA:19,27 +DA:22,27 +DA:23,27 +DA:25,27 +DA:26,27 +DA:28,27 +DA:29,1 +DA:31,26 +DA:32,1 +DA:34,25 +DA:35,1 +DA:40,55 +DA:41,6 +DA:42,6 +DA:44,1 +DA:47,49 +DA:51,121 +DA:55,124 +DA:56,0 +DA:59,124 +DA:60,122 +DA:61,122 +DA:64,2 +DA:65,2 +DA:70,124 +DA:71,124 +DA:72,3 +DA:73,3 +DA:76,121 +DA:77,121 +DA:78,121 +DA:80,121 +DA:81,125 +DA:82,125 +DA:85,121 +DA:89,128 +DA:90,128 +DA:91,2 +DA:92,2 +DA:94,126 +DA:99,5 +DA:103,3 +DA:107,5 +DA:111,1 +DA:112,0 +DA:115,1 +DA:116,0 +DA:119,1 +DA:120,1 +DA:121,1 +DA:122,1 +DA:126,25 +DA:127,25 +DA:128,25 +DA:129,25 +DA:133,9 +DA:134,3 +DA:136,6 +DA:137,6 +DA:138,9 +DA:140,1 +DA:145,4 +DA:146,0 +DA:149,4 +DA:151,4 +DA:152,4 +DA:153,1 +DA:157,4 +DA:158,4 +DA:160,3 +DA:161,3 +DA:163,0 +DA:172,1 +DA:174,1 +DA:175,2 +DA:176,2 +DA:178,2 +DA:179,2 +DA:180,1 +DA:185,2 +LF:84 +LH:79 +BRDA:21,0,0,27 +BRDA:22,1,0,27 +BRDA:22,1,1,0 +BRDA:23,2,0,27 +BRDA:23,2,1,1 +BRDA:28,3,0,1 +BRDA:28,3,1,26 +BRDA:31,4,0,1 +BRDA:31,4,1,25 +BRDA:34,5,0,1 +BRDA:34,5,1,24 +BRDA:40,6,0,6 +BRDA:40,6,1,49 +BRDA:55,7,0,0 +BRDA:55,7,1,124 +BRDA:59,8,0,122 +BRDA:59,8,1,2 +BRDA:71,9,0,3 +BRDA:71,9,1,121 +BRDA:90,10,0,2 +BRDA:90,10,1,126 +BRDA:111,11,0,0 +BRDA:111,11,1,1 +BRDA:119,12,0,1 +BRDA:119,12,1,0 +BRDA:133,13,0,3 +BRDA:133,13,1,6 +BRDA:133,14,0,9 +BRDA:133,14,1,6 +BRDA:133,14,2,5 +BRDA:137,15,0,3 +BRDA:137,15,1,3 +BRDA:138,16,0,9 +BRDA:138,16,1,4 +BRDA:144,17,0,4 +BRDA:145,18,0,0 +BRDA:145,18,1,4 +BRDA:160,19,0,3 +BRDA:160,19,1,0 +BRDA:169,20,0,1 +BRDA:170,21,0,1 +BRDA:178,22,0,2 +BRDA:178,22,1,0 +BRDA:179,23,0,1 +BRDA:179,23,1,1 +BRDA:185,24,0,2 +BRDA:185,24,1,0 +BRF:47 +BRH:39 +end_of_record +TN: +SF:src/relayer/ErrorMapper.ts +FN:33,constructor +FN:43,attach +FN:53,(anonymous_2) +FN:58,handleMessage +FN:78,detach +FN:86,dispose +FNF:6 +FNH:6 +FNDA:7,constructor +FNDA:10,attach +FNDA:6,(anonymous_2) +FNDA:6,handleMessage +FNDA:12,detach +FNDA:3,dispose +DA:12,2 +DA:30,7 +DA:31,7 +DA:34,7 +DA:35,7 +DA:36,7 +DA:44,10 +DA:45,1 +DA:50,9 +DA:52,9 +DA:53,28 +DA:54,28 +DA:61,6 +DA:62,2 +DA:65,4 +DA:66,4 +DA:68,4 +DA:72,4 +DA:74,4 +DA:79,12 +DA:80,15 +DA:82,12 +DA:87,3 +DA:88,3 +LF:24 +LH:24 +BRDA:33,0,0,7 +BRDA:38,1,0,7 +BRDA:38,1,1,6 +BRDA:44,2,0,1 +BRDA:44,2,1,9 +BRDA:61,3,0,2 +BRDA:61,3,1,4 +BRDA:61,4,0,6 +BRDA:61,4,1,6 +BRDA:61,4,2,6 +BRDA:61,4,3,6 +BRDA:66,5,0,0 +BRDA:66,5,1,4 +BRDA:72,6,0,0 +BRDA:72,6,1,4 +BRF:15 +BRH:13 +end_of_record +TN: +SF:src/relayer/WebSocketRelayer.ts +FN:41,constructor +FN:50,state +FN:59,acquireLock +FN:64,(anonymous_3) +FN:69,releaseLock +FN:78,emitStateChange +FN:92,connect +FN:116,wsCtor +FN:126,establishConnection +FN:133,(anonymous_9) +FN:148,(anonymous_10) +FN:157,(anonymous_11) +FN:161,(anonymous_12) +FN:173,(anonymous_13) +FN:187,safeInvokeHandler +FN:191,(anonymous_15) +FN:200,handleMessageSafe +FN:222,shouldAttemptReconnect +FN:231,flushPendingMessages +FN:235,(anonymous_19) +FN:239,attemptReconnect +FN:248,(anonymous_21) +FN:259,on +FN:265,(anonymous_23) +FN:276,onStateChange +FN:279,(anonymous_25) +FN:284,off +FN:294,send +FN:319,disconnect +FN:335,destroy +FNF:30 +FNH:24 +FNDA:34,constructor +FNDA:98,state +FNDA:147,acquireLock +FNDA:118,(anonymous_3) +FNDA:147,releaseLock +FNDA:95,emitStateChange +FNDA:47,connect +FNDA:28,wsCtor +FNDA:28,establishConnection +FNDA:28,(anonymous_9) +FNDA:27,(anonymous_10) +FNDA:1119,(anonymous_11) +FNDA:2,(anonymous_12) +FNDA:1,(anonymous_13) +FNDA:1065,safeInvokeHandler +FNDA:0,(anonymous_15) +FNDA:1119,handleMessageSafe +FNDA:0,shouldAttemptReconnect +FNDA:27,flushPendingMessages +FNDA:0,(anonymous_19) +FNDA:0,attemptReconnect +FNDA:0,(anonymous_21) +FNDA:39,on +FNDA:16,(anonymous_23) +FNDA:1,onStateChange +FNDA:1,(anonymous_25) +FNDA:0,off +FNDA:153,send +FNDA:2,disconnect +FNDA:36,destroy +DA:27,34 +DA:28,34 +DA:29,34 +DA:30,34 +DA:31,34 +DA:32,34 +DA:33,34 +DA:34,34 +DA:35,34 +DA:38,34 +DA:39,34 +DA:42,34 +DA:43,34 +DA:44,34 +DA:45,34 +DA:46,0 +DA:51,98 +DA:60,147 +DA:61,29 +DA:62,29 +DA:64,118 +DA:65,118 +DA:70,147 +DA:71,147 +DA:72,118 +DA:74,29 +DA:79,95 +DA:81,92 +DA:82,92 +DA:83,92 +DA:84,3 +DA:85,3 +DA:87,0 +DA:93,47 +DA:94,0 +DA:96,47 +DA:97,47 +DA:98,0 +DA:101,47 +DA:102,47 +DA:103,47 +DA:104,19 +DA:107,28 +DA:108,28 +DA:109,28 +DA:111,47 +DA:112,47 +DA:117,28 +DA:118,28 +DA:120,0 +DA:121,0 +DA:123,0 +DA:127,28 +DA:128,28 +DA:129,0 +DA:130,0 +DA:133,28 +DA:134,28 +DA:136,28 +DA:137,28 +DA:140,0 +DA:142,28 +DA:143,0 +DA:144,0 +DA:146,28 +DA:148,28 +DA:149,27 +DA:150,27 +DA:151,27 +DA:152,27 +DA:153,27 +DA:154,27 +DA:157,28 +DA:158,1119 +DA:161,28 +DA:162,2 +DA:163,2 +DA:164,0 +DA:165,0 +DA:167,2 +DA:168,2 +DA:169,0 +DA:173,28 +DA:174,1 +DA:175,1 +DA:176,1 +DA:180,28 +DA:182,0 +DA:188,1065 +DA:189,1065 +DA:190,1065 +DA:191,0 +DA:192,0 +DA:196,1 +DA:201,1119 +DA:204,1117 +DA:205,1117 +DA:207,3 +DA:210,1114 +DA:212,1114 +DA:213,1114 +DA:215,1062 +DA:216,1065 +DA:223,0 +DA:232,27 +DA:233,27 +DA:234,27 +DA:235,0 +DA:240,0 +DA:242,0 +DA:243,0 +DA:244,0 +DA:246,0 +DA:247,0 +DA:248,0 +DA:249,0 +DA:251,0 +DA:255,0 +DA:260,39 +DA:261,36 +DA:263,39 +DA:265,39 +DA:266,16 +DA:267,16 +DA:268,16 +DA:269,16 +DA:270,16 +DA:277,1 +DA:279,1 +DA:280,1 +DA:285,0 +DA:286,0 +DA:287,0 +DA:288,0 +DA:289,0 +DA:295,153 +DA:296,0 +DA:299,153 +DA:300,53 +DA:301,53 +DA:304,100 +DA:305,100 +DA:306,100 +DA:307,100 +DA:308,0 +DA:309,0 +DA:312,100 +DA:313,100 +DA:315,100 +DA:320,2 +DA:321,2 +DA:322,2 +DA:324,2 +DA:325,2 +DA:326,2 +DA:330,2 +DA:332,2 +DA:336,36 +DA:337,36 +DA:338,36 +DA:339,36 +DA:340,36 +DA:342,36 +DA:343,16 +DA:344,16 +DA:345,16 +DA:346,16 +DA:347,16 +DA:351,16 +DA:354,36 +DA:355,36 +DA:356,36 +DA:357,36 +DA:358,36 +LF:174 +LH:136 +BRDA:43,0,0,34 +BRDA:43,0,1,7 +BRDA:44,1,0,34 +BRDA:44,1,1,7 +BRDA:45,2,0,0 +BRDA:45,2,1,34 +BRDA:52,3,0,98 +BRDA:52,3,1,27 +BRDA:60,4,0,29 +BRDA:60,4,1,118 +BRDA:71,5,0,118 +BRDA:71,5,1,29 +BRDA:79,6,0,3 +BRDA:79,6,1,92 +BRDA:93,7,0,0 +BRDA:93,7,1,47 +BRDA:97,8,0,0 +BRDA:97,8,1,47 +BRDA:103,9,0,19 +BRDA:103,9,1,28 +BRDA:103,10,0,47 +BRDA:103,10,1,19 +BRDA:117,11,0,28 +BRDA:117,11,1,0 +BRDA:117,12,0,28 +BRDA:117,12,1,28 +BRDA:120,13,0,0 +BRDA:120,13,1,0 +BRDA:128,14,0,0 +BRDA:128,14,1,28 +BRDA:142,15,0,0 +BRDA:142,15,1,28 +BRDA:149,16,0,0 +BRDA:149,16,1,27 +BRDA:163,17,0,0 +BRDA:163,17,1,2 +BRDA:168,18,0,0 +BRDA:168,18,1,2 +BRDA:168,19,0,2 +BRDA:168,19,1,1 +BRDA:174,20,0,1 +BRDA:174,20,1,0 +BRDA:190,21,0,0 +BRDA:190,21,1,1065 +BRDA:190,22,0,1065 +BRDA:190,22,1,0 +BRDA:201,23,0,2 +BRDA:201,23,1,1117 +BRDA:201,24,0,1119 +BRDA:201,24,1,1118 +BRDA:210,25,0,0 +BRDA:210,25,1,1114 +BRDA:210,26,0,1114 +BRDA:210,26,1,1114 +BRDA:210,26,2,1114 +BRDA:210,26,3,1114 +BRDA:213,27,0,52 +BRDA:213,27,1,1062 +BRDA:224,28,0,0 +BRDA:224,28,1,0 +BRDA:224,28,2,0 +BRDA:224,28,3,0 +BRDA:224,28,4,0 +BRDA:240,29,0,0 +BRDA:240,29,1,0 +BRDA:244,30,0,0 +BRDA:244,30,1,0 +BRDA:249,31,0,0 +BRDA:249,31,1,0 +BRDA:260,32,0,36 +BRDA:260,32,1,3 +BRDA:267,33,0,16 +BRDA:267,33,1,0 +BRDA:269,34,0,16 +BRDA:269,34,1,0 +BRDA:286,35,0,0 +BRDA:286,35,1,0 +BRDA:288,36,0,0 +BRDA:288,36,1,0 +BRDA:295,37,0,0 +BRDA:295,37,1,153 +BRDA:299,38,0,53 +BRDA:299,38,1,100 +BRDA:299,39,0,153 +BRDA:299,39,1,100 +BRDA:306,40,0,0 +BRDA:306,40,1,100 +BRDA:307,41,0,0 +BRDA:307,41,1,100 +BRDA:307,42,0,100 +BRDA:307,42,1,100 +BRDA:324,43,0,2 +BRDA:324,43,1,0 +BRDA:342,44,0,16 +BRDA:342,44,1,20 +BRF:95 +BRH:59 +end_of_record +TN: +SF:src/types/index.ts +FNF:0 +FNH:0 +LF:0 +LH:0 +BRF:0 +BRH:0 +end_of_record diff --git a/docs/api.md b/docs/api.md index edf5a21..7f968ae 100644 --- a/docs/api.md +++ b/docs/api.md @@ -260,6 +260,11 @@ streamProgress(streamInfo) // → 0.42 (0–1 fraction elapsed) withdrawableLocal(streamInfo) // → bigint (client-side estimate, no RPC call) ``` +> **Performance:** `toStroops` and `fromStroops` use a memoised `BigInt(10 ** decimals)` cache +> so the exponent is computed only once per unique `decimals` value. This yields ~20% faster +> conversions when called repeatedly with the same token precision (e.g. 7 decimals for native +> Stellar asset, 18 for USDC). + `withdrawableLocal` is useful for building live counters without polling the chain on every render tick. --- diff --git a/docs/architecture.md b/docs/architecture.md index 7ff8140..9c8ba9e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,9 +14,12 @@ client.ts — ConduitClient: owns config, instantiates the three modules ├─ factory.ts — FactoryModule: streamCount/streamAddress/streamsBySender/streamsByRecipient/protocolFeeBps └─ governor.ts — GovernorModule: (config reads — see docs/api.md) soroban.ts — buildContractCallTx/simulateReadOnly + NETWORK_PASSPHRASE/DEFAULT_RPC tables -events.ts — subscribeToStream: polls getEvents(), dispatches to typed handlers +events.ts — subscribeToStream: polls getEvents() (via shared getServer() cache), dispatches to typed handlers +indexer.ts — GraphQLIndexer: GraphQL queries + subscriptions over WebSocket or SSE/HTTP fallback +builder.ts — StreamBuilder / ConduitBatcher: fluent builder + batched validation; pendingQueue uses Set for O(1) ops errors.ts — ConduitError + ErrorCode, mapped from on-chain contract error codes -utils.ts — toStroops/fromStroops/calculateRate/streamProgress/withdrawableLocal (pure, no RPC) +utils.ts — toStroops/fromStroops/calculateRate/streamProgress/withdrawableLocal (pure, no RPC); + toStroops/fromStroops use a memoised pow10 cache for ~20% faster conversions contracts/*-abi.ts — generated-style ABI/method-name constants per contract ``` diff --git a/package-lock.json b/package-lock.json index 93faa14..4aa8525 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@types/node": "^20.14.0", "@typescript-eslint/eslint-plugin": "^7.14.0", "@typescript-eslint/parser": "^7.14.0", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^8.57.0", "rollup": "^4.18.0", "typescript": "^5.5.3", @@ -27,6 +28,66 @@ "@stellar/stellar-sdk": "^12.0.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -210,6 +271,16 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -217,6 +288,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -1275,6 +1357,37 @@ "dev": true, "license": "ISC" }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -1504,6 +1617,28 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2667,6 +2802,13 @@ "node": ">= 0.4" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -2867,6 +3009,52 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", @@ -3229,6 +3417,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/package.json b/package.json index 836d305..6a590b1 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@types/node": "^20.14.0", "@typescript-eslint/eslint-plugin": "^7.14.0", "@typescript-eslint/parser": "^7.14.0", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^8.57.0", "rollup": "^4.18.0", "typescript": "^5.5.3", diff --git a/src/builder.ts b/src/builder.ts index 5f611f7..d9c3508 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -55,7 +55,7 @@ export class StreamBuilder { private _amount?: number | undefined; private _ratePerSecond?: number | bigint | undefined; - private pendingQueue: Array> = []; + private pendingQueue: Set> = new Set(); private activeTimers: Set = new Set(); private isDestroyed = false; private _semaphore: Semaphore; @@ -189,7 +189,7 @@ export class StreamBuilder { } // Backpressure: reject if queue is full - if (this.pendingQueue.length >= this._maxQueueSize) { + if (this.pendingQueue.size >= this._maxQueueSize) { throw new Error( `StreamBuilder queue is full (${this._maxQueueSize} pending). ` + 'Retry later or increase maxQueueSize.' @@ -197,7 +197,8 @@ export class StreamBuilder { } const payload = this.build(); - this.pendingQueue.push(payload as unknown as Record); + const payloadRecord = payload as unknown as Record; + this.pendingQueue.add(payloadRecord); const maxRetries = options.maxRetries ?? 3; const baseRetryDelay = options.retryDelayMs ?? 100; @@ -217,11 +218,8 @@ export class StreamBuilder { } try { - const result = await submitFn(payload as unknown as Record); - const index = this.pendingQueue.indexOf(payload as unknown as Record); - if (index !== -1) { - this.pendingQueue.splice(index, 1); - } + const result = await submitFn(payloadRecord); + this.pendingQueue.delete(payloadRecord); return result; } catch (err) { if (signal?.aborted) { @@ -273,7 +271,7 @@ export class StreamBuilder { clearTimeout(timer); } this.activeTimers.clear(); - this.pendingQueue = []; + this.pendingQueue.clear(); } private static _validateAddress(address: string, field: string): string { diff --git a/src/events.ts b/src/events.ts index 0a43c7c..129db8e 100644 --- a/src/events.ts +++ b/src/events.ts @@ -16,7 +16,7 @@ import type { TopUpEvent, ClawbackEvent, } from './types/index.js'; -import { scValToI128, scValToU64 } from './soroban.js'; +import { scValToI128, scValToU64, getServer } from './soroban.js'; // ── Event topic names (match symbol_short!() values in Rust) ───────────────── @@ -81,7 +81,7 @@ export function subscribeToStream( streamAddress: string, handlers: StreamEventHandlers, ): Subscription { - const server = new SorobanRpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith('http://') }); + const server = getServer(rpcUrl); const pollInterval = handlers.pollInterval ?? 5000; let startLedger = 0; // updated after each successful poll let stopped = false; diff --git a/src/graph-sync-agent.ts b/src/graph-sync-agent.ts index b9c5109..3f00bb2 100644 --- a/src/graph-sync-agent.ts +++ b/src/graph-sync-agent.ts @@ -11,32 +11,27 @@ export class GraphSyncAgent { * Utilizes an atomic state transition / strict locking mechanism to prevent race conditions * and unhandled promise rejections under poor network conditions. */ - async syncState(networkFetcher: () => Promise): Promise { - const syncOperation = this.updateQueue.then(async () => { - const rawDelta = await networkFetcher(); - - // Proper error-boundary handling - if (typeof rawDelta !== 'number' || !Number.isFinite(rawDelta)) { - throw new Error("Invalid network delta response"); - } - - // Proper fractional decimal rounding to handle floating point impurities (e.g. 0.1 + 0.2 = 0.30000000000000004) - // Scale to 8 decimal places to safely add fractional amounts - const scale = 100000000; - const scaledState = Math.round(this.currentState * scale); - const scaledDelta = Math.round(rawDelta * scale); - - this.currentState = (scaledState + scaledDelta) / scale; - - return this.currentState; - }); - - this.updateQueue = syncOperation.then( - () => undefined, - () => undefined - ); - - return syncOperation; + async syncState(networkFetcher: () => Promise): Promise { + const syncOperation = this.updateQueue.then(async () => { + const rawDelta = await networkFetcher(); + + if (typeof rawDelta !== 'number' || !Number.isFinite(rawDelta)) { + throw new Error("Invalid network delta response"); + } + + // Scale to 8 decimal places for safe fractional accumulation + const scale = 100000000; + const scaledState = Math.round(this.currentState * scale); + const scaledDelta = Math.round(rawDelta * scale); + + this.currentState = (scaledState + scaledDelta) / scale; + + return this.currentState; + }); + + this.updateQueue = syncOperation.catch(() => undefined); + + return syncOperation; } getCurrentState(): number { diff --git a/src/index.ts b/src/index.ts index 9384964..7cfd4dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ export { UNKNOWN_CONTRACT_ERROR_CODE, } from './errors.js'; export type { ConduitContract } from './errors.js'; +export type { StreamConfig, StreamState, Recipient } from './types/index.js'; export * from './types/index.js'; export * from './adapters/index.js'; export { FeeEstimator } from './fee-estimator.js'; diff --git a/src/soroban.ts b/src/soroban.ts index adf3ee2..6f588fa 100644 --- a/src/soroban.ts +++ b/src/soroban.ts @@ -3,6 +3,10 @@ * * Wraps @stellar/stellar-sdk's SorobanRpc to provide a thin * simulate → assemble → sign → submit pipeline. + * + * Performance note: SorobanRpc.Server instances are cached per RPC URL + * so that repeated calls (e.g. simulate → submit → poll) reuse the + * same HTTP client, avoiding redundant TLS and connection-pool setup. */ import { @@ -29,6 +33,30 @@ export const NETWORK_PASSPHRASE: Record = { local: Networks.STANDALONE, }; +/** + * Cache SorobanRpc.Server instances per RPC URL so that callers + * who invoke multiple RPC methods sequentially or in parallel do + * not create a new HTTP client for every single call. + * Keyed by rpcUrl so different network targets get independent caches. + */ +const _serverCache = new Map(); + +export function getServer(rpcUrl: string): SorobanRpc.Server { + let srv = _serverCache.get(rpcUrl); + if (!srv) { + srv = new SorobanRpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith('http://') }); + _serverCache.set(rpcUrl, srv); + } + return srv; +} + +/** + * Clear the server cache. Intended for use in tests. + */ +export function clearServerCache(): void { + _serverCache.clear(); +} + export interface ConfirmationPollingOptions { pollIntervalMs?: number; maxAttempts?: number; @@ -58,7 +86,7 @@ export async function buildContractCallTx( method: string, args: xdr.ScVal[], ): Promise> { - const server = new SorobanRpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith('http://') }); + const server = getServer(rpcUrl); let account; try { @@ -89,7 +117,7 @@ export async function invokeContract( tx: ReturnType, pollingOptions: ConfirmationPollingOptions = {}, ): Promise { - const server = new SorobanRpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith('http://') }); + const server = getServer(rpcUrl); const polling = normalizePollingOptions(pollingOptions); // Simulate @@ -148,7 +176,7 @@ export async function simulateReadOnly( passphrase: string, tx: ReturnType, ): Promise { - const server = new SorobanRpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith('http://') }); + const server = getServer(rpcUrl); let result; try { diff --git a/src/streams.ts b/src/streams.ts index 7d971c7..431e8b8 100644 --- a/src/streams.ts +++ b/src/streams.ts @@ -70,6 +70,7 @@ export class StreamsModule { private readonly passphrase: string; private readonly callerAddr: string; private readonly _factory: FactoryModule; + private _cachedServer?: SorobanRpc.Server; private activeWallet?: WalletAdapter; constructor(private readonly config: ConduitConfig) { @@ -466,7 +467,12 @@ export class StreamsModule { } private _server(): SorobanRpc.Server { - return new SorobanRpc.Server(this.rpcUrl, { allowHttp: this.rpcUrl.startsWith('http://') }); + if (!this._cachedServer) { + this._cachedServer = new SorobanRpc.Server(this.rpcUrl, { + allowHttp: this.rpcUrl.startsWith('http://'), + }); + } + return this._cachedServer; } private async _resolveAddr(id: bigint): Promise { diff --git a/src/tests/builder-advanced.test.ts b/src/tests/builder-advanced.test.ts new file mode 100644 index 0000000..918de59 --- /dev/null +++ b/src/tests/builder-advanced.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { StrKey, Keypair } from '@stellar/stellar-sdk'; +import { StreamBuilder, ConduitBatcher } from '../builder.js'; + +const VALID_TOKEN = (() => { + const buf = Buffer.alloc(32); + require('crypto').randomFillSync(buf); + return StrKey.encodeContract(buf); +})(); + +const VALID_SENDER = Keypair.random().publicKey(); +const VALID_RECIP = Keypair.random().publicKey(); + +describe('StreamBuilder — advanced scenarios', () => { + describe('destroy lifecycle', () => { + it('throws on build after destroy', () => { + const builder = new StreamBuilder(); + builder.token(VALID_TOKEN).sender(VALID_SENDER).recipient(VALID_RECIP).amount(100); + builder.cleanup(); + expect(() => builder.build()).toThrow('StreamBuilder has been destroyed'); + }); + + it('throws on submit after destroy', async () => { + const builder = new StreamBuilder(); + builder.token(VALID_TOKEN).sender(VALID_SENDER).recipient(VALID_RECIP).amount(100); + builder.cleanup(); + await expect(builder.submit(vi.fn())).rejects.toThrow('StreamBuilder has been destroyed'); + }); + }); + + describe('ratePerSecond validation', () => { + it('rejects zero bigint rate', () => { + expect(() => new StreamBuilder().ratePerSecond(0n)).toThrow('must be a positive value'); + }); + + it('rejects negative bigint rate', () => { + expect(() => new StreamBuilder().ratePerSecond(-1n)).toThrow('must be a positive value'); + }); + + it('rejects zero number rate', () => { + expect(() => new StreamBuilder().ratePerSecond(0)).toThrow('must be a positive finite number'); + }); + + it('rejects negative number rate', () => { + expect(() => new StreamBuilder().ratePerSecond(-1)).toThrow('must be a positive finite number'); + }); + + it('rejects NaN rate', () => { + expect(() => new StreamBuilder().ratePerSecond(NaN)).toThrow('must be a positive finite number'); + }); + }); + + describe('amount validation', () => { + it('rejects zero amount', () => { + expect(() => new StreamBuilder().amount(0)).toThrow('amount must be a positive finite number'); + }); + + it('rejects negative amount', () => { + expect(() => new StreamBuilder().amount(-100)).toThrow('amount must be a positive finite number'); + }); + }); + + describe('address validation', () => { + it('rejects empty token', () => { + expect(() => new StreamBuilder().token('')).toThrow('must be a non-empty string'); + }); + + it('rejects empty sender', () => { + expect(() => new StreamBuilder().sender('')).toThrow('must be a non-empty string'); + }); + + it('rejects whitespace-only recipient', () => { + expect(() => new StreamBuilder().recipient(' ')).toThrow('must be a non-empty string'); + }); + + it('rejects G-address as token', () => { + expect(() => new StreamBuilder().token(VALID_SENDER)).toThrow('valid Soroban contract ID'); + }); + + it('rejects invalid sender', () => { + expect(() => new StreamBuilder().sender('NOTVALID')).toThrow('valid Stellar public key'); + }); + }); + + describe('submit() with abort signal', () => { + it('throws AbortError when signal is already aborted', async () => { + const builder = new StreamBuilder(); + builder.token(VALID_TOKEN).sender(VALID_SENDER).recipient(VALID_RECIP).amount(100); + const controller = new AbortController(); + controller.abort(); + await expect( + builder.submit(vi.fn(), { signal: controller.signal }), + ).rejects.toThrow('Aborted'); + }); + + it('throws when submitFn is not a function', async () => { + const builder = new StreamBuilder(); + builder.token(VALID_TOKEN).sender(VALID_SENDER).recipient(VALID_RECIP).amount(100); + await expect(builder.submit(null as any)).rejects.toThrow('submitFn must be a valid function'); + }); + }); +}); + +describe('ConduitBatcher — advanced scenarios', () => { + const validItem = { token: VALID_TOKEN, sender: VALID_SENDER, recipient: VALID_RECIP, amount: 100 }; + + describe('validatePayload', () => { + it('rejects null payload', () => { + const result = ConduitBatcher.execute(null as any); + expect(result.success).toBe(false); + expect(result.errors).toContain('Batch payload cannot be null or undefined'); + }); + + it('rejects non-array payload', () => { + const result = ConduitBatcher.execute({} as any); + expect(result.success).toBe(false); + expect(result.errors).toContain('Batch payload must be an array'); + }); + + it('rejects non-object items', () => { + const result = ConduitBatcher.execute(['string' as any]); + expect(result.success).toBe(false); + expect(result.errors!.length).toBeGreaterThan(0); + }); + + it('rejects null items', () => { + const result = ConduitBatcher.execute([null as any]); + expect(result.success).toBe(false); + expect(result.errors!.length).toBeGreaterThan(0); + }); + + it('validates token address format', () => { + const result = ConduitBatcher.execute([{ ...validItem, token: 'INVALID' }]); + expect(result.success).toBe(false); + expect(result.errors![0]).toContain('token must be a valid Soroban contract ID'); + }); + + it('validates sender address format', () => { + const result = ConduitBatcher.execute([{ ...validItem, sender: 'INVALID' }]); + expect(result.success).toBe(false); + expect(result.errors![0]).toContain('sender must be a valid Stellar public key'); + }); + + it('validates zero amount', () => { + const result = ConduitBatcher.execute([{ ...validItem, amount: 0 }]); + expect(result.success).toBe(false); + expect(result.errors![0]).toContain('amount must be a positive finite number'); + }); + + it('validates negative amount', () => { + const result = ConduitBatcher.execute([{ ...validItem, amount: -1 }]); + expect(result.success).toBe(false); + expect(result.errors![0]).toContain('amount must be a positive finite number'); + }); + }); + + describe('chunking', () => { + it('returns 0 chunks for empty array', () => { + const result = ConduitBatcher.execute([]); + expect(result.success).toBe(true); + expect(result.chunks).toBe(0); + }); + + it('chunks correctly with default maxBatchSize', () => { + const streams = Array.from({ length: 120 }, () => validItem); + const result = ConduitBatcher.execute(streams); + expect(result.success).toBe(true); + expect(result.chunks).toBe(3); + }); + + it('respects custom maxBatchSize', () => { + const streams = Array.from({ length: 10 }, () => validItem); + const result = ConduitBatcher.execute(streams, { maxBatchSize: 3 }); + expect(result.success).toBe(true); + expect(result.chunks).toBe(4); + }); + }); + + describe('destroy and reset lifecycle', () => { + afterEach(() => { + ConduitBatcher.reset(); + }); + + it('throws after destroy', () => { + ConduitBatcher.destroy(); + expect(() => ConduitBatcher.execute([validItem])).toThrow('ConduitBatcher has been destroyed'); + }); + + it('works again after reset', () => { + ConduitBatcher.destroy(); + ConduitBatcher.reset(); + expect(ConduitBatcher.execute([validItem]).success).toBe(true); + }); + + it('aborts before processing when signal is pre-aborted', async () => { + const ac = new AbortController(); + ac.abort(); + const result = await ConduitBatcher.executeAsync( + [{ operation: 'create_stream', params: {} }], + ac.signal, + ); + expect(result.success).toBe(false); + expect(result.errors).toContain('Operation aborted'); + }); + + it('handles non-aborted signal by processing normally', async () => { + const ac = new AbortController(); + const result = await ConduitBatcher.executeAsync( + [{ operation: 'create_stream', params: {} }], + ac.signal, + ); + expect(result.success).toBe(true); + }); + }); +}); + +describe('StreamBuilder — submit retry exhaustion', () => { + it('throws after all retries exhausted', async () => { + const builder = new StreamBuilder(); + builder.token(VALID_TOKEN).sender(VALID_SENDER).recipient(VALID_RECIP).amount(100); + await expect( + builder.submit( + async () => { throw new Error('fail'); }, + { maxRetries: 0, retryDelayMs: 1 }, + ), + ).rejects.toThrow('retries'); + }); + + it('aborts after retry exhausted in submit', async () => { + const builder = new StreamBuilder(); + builder.token(VALID_TOKEN).sender(VALID_SENDER).recipient(VALID_RECIP).amount(100); + const ac = new AbortController(); + const submitPromise = builder.submit( + async () => { + ac.abort(); + throw new Error('fail'); + }, + { signal: ac.signal, maxRetries: 1, retryDelayMs: 4 }, + ); + await expect(submitPromise).rejects.toThrow(); + }); +}); diff --git a/src/tests/edge-cases.test.ts b/src/tests/edge-cases.test.ts new file mode 100644 index 0000000..3ee452b --- /dev/null +++ b/src/tests/edge-cases.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SorobanRpc, xdr, nativeToScVal, StrKey, Keypair } from '@stellar/stellar-sdk'; +import crypto from 'crypto'; + +// ── soroban.ts: simulateReadOnly error paths ─────────────────────────────────── + +describe('simulateReadOnly — error paths', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws when simulation returns no result', async () => { + vi.spyOn(SorobanRpc.Server.prototype, 'simulateTransaction') + .mockResolvedValue({ result: null } as any); + + const { simulateReadOnly, clearServerCache } = await import('../soroban.js'); + clearServerCache(); + await expect( + simulateReadOnly('http://localhost:8000', 'testnet', {} as any), + ).rejects.toThrow('Simulation returned no result'); + }); + + it('throws on simulation error', async () => { + vi.spyOn(SorobanRpc.Server.prototype, 'simulateTransaction') + .mockResolvedValue({ error: 'Contract error' } as any); + + const { simulateReadOnly, clearServerCache } = await import('../soroban.js'); + clearServerCache(); + await expect( + simulateReadOnly('http://localhost:8000', 'testnet', {} as any), + ).rejects.toThrow('Simulation error'); + }); +}); + +// ── soroban.ts: getTokenDecimals ─────────────────────────────────────────────── + +describe('getTokenDecimals', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns the u32 value', async () => { + const pk = Keypair.random().publicKey(); + let seq = '1'; + vi.spyOn(SorobanRpc.Server.prototype, 'getAccount') + .mockResolvedValue({ + accountId: () => pk, + sequenceNumber: () => seq, + incrementSequenceNumber: () => { seq = String(Number(seq) + 1); return seq; }, + } as any); + vi.spyOn(SorobanRpc.Server.prototype, 'simulateTransaction') + .mockResolvedValue({ + result: { + retval: { toXDR: () => xdr.ScVal.scvU32(7).toXDR() }, + }, + } as any); + + const buf = Buffer.alloc(32); + crypto.randomFillSync(buf); + const contractId = StrKey.encodeContract(buf); + + const { getTokenDecimals, clearServerCache } = await import('../soroban.js'); + clearServerCache(); + const decimals = await getTokenDecimals('http://localhost:8000', 'testnet', pk, contractId); + expect(decimals).toBe(7); + }); +}); + +// ── nonce-manager.ts: NaN branch ─────────────────────────────────────────────── + +describe('NonceManager — NaN handling', () => { + it('increments currentNonce when network returns NaN', async () => { + const { NonceManager } = await import('../nonce-manager.js'); + const nm = new NonceManager(); + const next = await nm.getNextNonce(async () => NaN); + expect(next).toBe(1); + }); + + it('increments on fetch error', async () => { + const { NonceManager } = await import('../nonce-manager.js'); + const nm = new NonceManager(); + const next = await nm.getNextNonce(async () => { throw new Error('network down'); }); + expect(next).toBe(1); + }); +}); + +// ── events.ts: addressField catch ────────────────────────────────────────────── + +describe('dispatchEvent — addressField fallback', () => { + it('handles invalid address in topic gracefully', async () => { + const { dispatchEvent } = await import('../events.js'); + const onCancel = vi.fn(); + dispatchEvent( + { + topic: [xdr.ScVal.scvSymbol('cancelled'), xdr.ScVal.scvSymbol('not_an_address')], + value: xdr.ScVal.scvVec([ + nativeToScVal(100n, { type: 'i128' }), + nativeToScVal(500n, { type: 'i128' }), + ]), + }, + { onCancel } as any, + ); + expect(onCancel).toHaveBeenCalledWith( + expect.objectContaining({ sender: '', refundAmount: 100n, withdrawnSoFar: 500n }), + ); + }); +}); + +// ── builder.ts: ConduitBatcher abort handling ──────────────────────────────── + +describe('ConduitBatcher — abort signal', () => { + it('resolves with error when aborted before processing', async () => { + const { ConduitBatcher } = await import('../builder.js'); + const ac = new AbortController(); + ac.abort(); + const result = await ConduitBatcher.executeAsync( + [{ operation: 'create_stream', params: {} }], + ac.signal, + ); + expect(result.success).toBe(false); + expect(result.errors).toContain('Operation aborted'); + }); +}); + +// ── streams.ts: _ensureCanMutate with signer ────────────────────────────────── + +describe('StreamsModule — _ensureCanMutate with signer', () => { + it('succeeds with only a signer', async () => { + const buf = Buffer.alloc(32); + crypto.randomFillSync(buf); + const contractId = StrKey.encodeContract(buf); + + const { StreamsModule } = await import('../streams.js'); + const streams = new StreamsModule({ + network: 'testnet', + factoryAddress: contractId, + signer: { sign: vi.fn(), publicKey: () => Keypair.random().publicKey() }, + } as any); + expect(() => (streams as any)._ensureCanMutate()).not.toThrow(); + }); +}); diff --git a/src/tests/errors.test.ts b/src/tests/errors.test.ts index 279d14e..f49176b 100644 --- a/src/tests/errors.test.ts +++ b/src/tests/errors.test.ts @@ -58,6 +58,16 @@ describe('ConduitError', () => { expect(governorErr.message).toMatch(/not authorized|governor authority/i); expect(streamErr.message).not.toBe(factoryErr.message); }); + + it('isKnown returns true for known codes', () => { + const err = new ConduitError('stream', StreamErrorCode.NothingToWithdraw); + expect(err.isKnown).toBe(true); + }); + + it('isKnown returns false for unknown codes', () => { + const err = new ConduitError('stream', 999); + expect(err.isKnown).toBe(false); + }); }); describe('ConduitError.fromContractError', () => { diff --git a/src/tests/factory.test.ts b/src/tests/factory.test.ts index 276dd6f..417d083 100644 --- a/src/tests/factory.test.ts +++ b/src/tests/factory.test.ts @@ -111,6 +111,15 @@ describe('FactoryModule — streamAddress()', () => { const addr = await new FactoryModule(cfg()).streamAddress(999n); expect(addr).toBeNull(); }); + + it('returns address string for non-void result', async () => { + const { FactoryModule } = await import('../factory.js'); + mockSimulate.mockResolvedValueOnce(_xdr.ScVal.scvU32(1)); + + const addr = await new FactoryModule(cfg()).streamAddress(1n); + expect(typeof addr).toBe('string'); + }); + }); describe('FactoryModule — protocolFeeBps()', () => { diff --git a/src/tests/fee-estimator-edge.test.ts b/src/tests/fee-estimator-edge.test.ts new file mode 100644 index 0000000..9e43114 --- /dev/null +++ b/src/tests/fee-estimator-edge.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { FeeEstimator } from '../fee-estimator.js'; + +describe('FeeEstimator — edge cases', () => { + describe('with minRefetchInterval', () => { + it('returns cached value within refetch interval', async () => { + const fetcher = vi.fn().mockResolvedValue(200); + const estimator = new FeeEstimator(100, { minRefetchIntervalMs: 10000 }); + + const first = await estimator.estimateFee(fetcher); + expect(first).toBe(200); + expect(fetcher).toHaveBeenCalledTimes(1); + + const second = await estimator.estimateFee(fetcher); + expect(second).toBe(200); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('fetches again after refetch interval expires', async () => { + const fetcher = vi.fn() + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(200); + const estimator = new FeeEstimator(50, { minRefetchIntervalMs: 1 }); + + const first = await estimator.estimateFee(fetcher); + expect(first).toBe(100); + + await new Promise(r => setTimeout(r, 5)); + + const second = await estimator.estimateFee(fetcher); + expect(second).toBe(200); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + }); + + describe('error handling', () => { + it('returns initial fee on network failure', async () => { + const estimator = new FeeEstimator(100); + const result = await estimator.estimateFee( + () => Promise.reject(new Error('Network down')), + ); + expect(result).toBe(100); + }); + + it('calls onError callback on failure', async () => { + const onError = vi.fn(); + const estimator = new FeeEstimator(50); + await estimator.estimateFee( + () => Promise.reject(new Error('Timeout')), + { onError }, + ); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('recovers after a failure', async () => { + let shouldFail = true; + const estimator = new FeeEstimator(50); + const fetcher = vi.fn().mockImplementation(() => { + if (shouldFail) return Promise.reject(new Error('Temporary')); + return Promise.resolve(200); + }); + + const first = await estimator.estimateFee(fetcher); + expect(first).toBe(50); + + shouldFail = false; + const second = await estimator.estimateFee(fetcher); + expect(second).toBe(200); + }); + + it('handles non-Error fetch failures', async () => { + const estimator = new FeeEstimator(100); + const result = await estimator.estimateFee( + () => Promise.reject('string error'), + ); + expect(result).toBe(100); + }); + }); + + describe('concurrent requests', () => { + it('deduplicates concurrent requests', async () => { + const fetcher = vi.fn().mockResolvedValue(300); + const estimator = new FeeEstimator(100); + + const results = await Promise.all( + Array.from({ length: 50 }, () => estimator.estimateFee(fetcher)), + ); + expect(results.every(r => r === 300)).toBe(true); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('deduplicates within refetch interval', async () => { + const fetcher = vi.fn().mockResolvedValue(500); + const estimator = new FeeEstimator(200, { minRefetchIntervalMs: 5000 }); + + const results = await Promise.all( + Array.from({ length: 20 }, () => estimator.estimateFee(fetcher)), + ); + expect(results.every(r => r === 500)).toBe(true); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + }); + + describe('state getters', () => { + it('tracks isEstimating during fetch', async () => { + const estimator = new FeeEstimator(100); + let resolveFetch: (v: number) => void; + const fetcher = () => new Promise(resolve => { resolveFetch = resolve; }); + + const promise = estimator.estimateFee(fetcher); + expect(estimator._isEstimating).toBe(true); + resolveFetch!(200); + await promise; + }); + + it('reports stale after error', async () => { + const estimator = new FeeEstimator(100); + await estimator.estimateFee(() => Promise.reject(new Error('fail'))); + expect(estimator.isStale).toBe(true); + expect(estimator.lastError).toBeTruthy(); + }); + + it('returns baseFee without network call', () => { + const estimator = new FeeEstimator(250); + expect(estimator.getBaseFee()).toBe(250); + }); + }); +}); diff --git a/src/tests/indexer.test.ts b/src/tests/indexer.test.ts new file mode 100644 index 0000000..b355258 --- /dev/null +++ b/src/tests/indexer.test.ts @@ -0,0 +1,789 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { GraphQLIndexer } from '../indexer.js'; + +describe('GraphQLIndexer', () => { + const endpoint = 'https://indexer.streamfi.io/graphql'; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('throws for empty endpoint', () => { + expect(() => new GraphQLIndexer('')).toThrow('endpoint must be a non-empty string'); + }); + + it('throws for whitespace-only endpoint', () => { + expect(() => new GraphQLIndexer(' ')).toThrow('endpoint must be a non-empty string'); + }); + + it('accepts valid endpoint', () => { + const indexer = new GraphQLIndexer(endpoint); + expect(indexer).toBeInstanceOf(GraphQLIndexer); + indexer.cleanup(); + }); + }); + + describe('query', () => { + it('throws if destroyed', async () => { + const indexer = new GraphQLIndexer(endpoint); + indexer.cleanup(); + await expect(indexer.query({ query: '{ test }' })).rejects.toThrow('has been destroyed'); + }); + + it('throws for null options', async () => { + const indexer = new GraphQLIndexer(endpoint); + await expect(indexer.query(null as any)).rejects.toThrow('cannot be null or undefined'); + indexer.cleanup(); + }); + + it('throws for empty query string', async () => { + const indexer = new GraphQLIndexer(endpoint); + await expect(indexer.query({ query: '' })).rejects.toThrow('cannot be null or empty'); + indexer.cleanup(); + }); + + it('throws for null query string', async () => { + const indexer = new GraphQLIndexer(endpoint); + await expect(indexer.query({ query: null as any })).rejects.toThrow('cannot be null or empty'); + indexer.cleanup(); + }); + + it('accepts null variables via default', async () => { + const origFetch = (globalThis as any).fetch; + (globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + } as Response); + const indexer = new GraphQLIndexer(endpoint); + const result = await indexer.query({ query: '{ test }', variables: null as any }); + expect(result).toEqual({}); + indexer.cleanup(); + (globalThis as any).fetch = origFetch; + }); + + it('throws for non-object variables', async () => { + const indexer = new GraphQLIndexer(endpoint); + await expect(indexer.query({ query: '{ test }', variables: 42 as any })).rejects.toThrow('must be an object'); + indexer.cleanup(); + }); + + it('throws if fetch is unavailable', async () => { + const origFetch = (globalThis as any).fetch; + (globalThis as any).fetch = undefined; + const indexer = new GraphQLIndexer(endpoint); + await expect(indexer.query({ query: '{ test }' })).rejects.toThrow('Fetch API is not available'); + indexer.cleanup(); + (globalThis as any).fetch = origFetch; + }); + + it('performs POST request and returns data', async () => { + const data = { data: { ok: true } }; + const origFetch = (globalThis as any).fetch; + (globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => data, + } as Response); + + const indexer = new GraphQLIndexer(endpoint); + const result = await indexer.query({ query: '{ test }', variables: { limit: 5 } }); + expect(result).toEqual(data); + indexer.cleanup(); + (globalThis as any).fetch = origFetch; + }); + + it('uses custom headers', async () => { + const origFetch = (globalThis as any).fetch; + const fetchFn = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + } as Response); + (globalThis as any).fetch = fetchFn; + + const indexer = new GraphQLIndexer(endpoint); + await indexer.query({ query: '{ test }', headers: { Authorization: 'Bearer tok' } }); + expect(fetchFn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer tok' }), + }), + ); + indexer.cleanup(); + (globalThis as any).fetch = origFetch; + }); + + it('throws on HTTP error', async () => { + const origFetch = (globalThis as any).fetch; + (globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + } as Response); + + const indexer = new GraphQLIndexer(endpoint); + await expect(indexer.query({ query: '{ test }' })).rejects.toThrow('status 400'); + indexer.cleanup(); + (globalThis as any).fetch = origFetch; + }); + + it('defaults variables to empty object', async () => { + const origFetch = (globalThis as any).fetch; + const fetchFn = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + } as Response); + (globalThis as any).fetch = fetchFn; + + const indexer = new GraphQLIndexer(endpoint); + await indexer.query({ query: '{ test }' }); + expect(fetchFn).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ + body: expect.stringContaining('"variables":{}'), + }), + ); + indexer.cleanup(); + (globalThis as any).fetch = origFetch; + }); + }); + + describe('subscribe', () => { + it('throws if destroyed', () => { + const indexer = new GraphQLIndexer(endpoint); + indexer.cleanup(); + expect(() => indexer.subscribe({ query: 'sub { x }', onData: () => {} })).toThrow('has been destroyed'); + }); + + it('throws for null options', () => { + const indexer = new GraphQLIndexer(endpoint); + expect(() => indexer.subscribe(null as any)).toThrow('cannot be null or undefined'); + indexer.cleanup(); + }); + + it('throws for empty query', () => { + const indexer = new GraphQLIndexer(endpoint); + expect(() => indexer.subscribe({ query: '', onData: () => {} } as any)).toThrow('cannot be null or empty'); + indexer.cleanup(); + }); + + it('throws if onData is not a function', () => { + const indexer = new GraphQLIndexer(endpoint); + expect(() => indexer.subscribe({ query: 'sub { x }', onData: null as any })).toThrow('must be a function'); + indexer.cleanup(); + }); + + it('accepts null variables (defaulted to {})', () => { + const indexer = new GraphQLIndexer(endpoint); + expect(() => + indexer.subscribe({ query: 'sub { x }', onData: () => {}, variables: null as any }), + ).not.toThrow(); + expect(indexer.getSubscriptionCount()).toBe(1); + indexer.cleanup(); + }); + + it('throws for non-object variables', () => { + const indexer = new GraphQLIndexer(endpoint); + expect(() => + indexer.subscribe({ query: 'sub { x }', onData: () => {}, variables: 42 as any }), + ).toThrow('must be an object'); + indexer.cleanup(); + }); + + it('tracks and returns subscription count', () => { + const indexer = new GraphQLIndexer(endpoint); + expect(indexer.getSubscriptionCount()).toBe(0); + + const sub1 = indexer.subscribe({ query: 'sub { a }', onData: () => {} }); + expect(indexer.getSubscriptionCount()).toBe(1); + + const sub2 = indexer.subscribe({ query: 'sub { b }', onData: () => {} }); + expect(indexer.getSubscriptionCount()).toBe(2); + + sub1.unsubscribe(); + expect(indexer.getSubscriptionCount()).toBe(1); + + sub2.unsubscribe(); + expect(indexer.getSubscriptionCount()).toBe(0); + + indexer.cleanup(); + }); + + it('cleanup unsubscribes all and rejects further subscribe calls', () => { + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { a }', onData: () => {} }); + indexer.subscribe({ query: 'sub { b }', onData: () => {} }); + expect(indexer.getSubscriptionCount()).toBe(2); + + indexer.cleanup(); + expect(indexer.getSubscriptionCount()).toBe(0); + expect(() => indexer.subscribe({ query: 'sub { c }', onData: () => {} })).toThrow('has been destroyed'); + }); + + it('multiple unsubscribe calls are idempotent', () => { + const indexer = new GraphQLIndexer(endpoint); + const sub = indexer.subscribe({ query: 'sub { a }', onData: () => {} }); + sub.unsubscribe(); + sub.unsubscribe(); + sub.unsubscribe(); + expect(indexer.getSubscriptionCount()).toBe(0); + indexer.cleanup(); + }); + + describe('WebSocket path', () => { + function makeWs() { + return { + readyState: 0, + send: vi.fn(), + close: vi.fn(), + onopen: null as (() => void) | null, + onmessage: null as ((e: any) => void) | null, + onerror: null as ((e: any) => void) | null, + onclose: null as (() => void) | null, + }; + } + + function stubGlobalWebSocket(ws: ReturnType) { + const orig = (globalThis as any).WebSocket; + const ctor = function (this: any, _url: string, _protocols?: string) { + return ws; + }; + ctor.prototype = {}; + (globalThis as any).WebSocket = ctor as any; + return orig; + } + + it('uses WebSocket when available', () => { + const ws = makeWs(); + const orig = stubGlobalWebSocket(ws); + const indexer = new GraphQLIndexer(endpoint); + const sub = indexer.subscribe({ query: 'sub { x }', onData: () => {} }); + expect(ws.send).toBeDefined(); + sub.unsubscribe(); + indexer.cleanup(); + (globalThis as any).WebSocket = orig; + }); + + it('calls onopen and sends init messages', () => { + const ws = makeWs(); + const orig = stubGlobalWebSocket(ws); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData: vi.fn() }); + + expect(ws.onopen).toBeDefined(); + ws.onopen!(); + + expect(ws.send).toHaveBeenCalledWith(expect.stringContaining('connection_init')); + expect(ws.send).toHaveBeenCalledWith(expect.stringContaining('subscribe')); + indexer.cleanup(); + (globalThis as any).WebSocket = orig; + }); + + it('forwards onmessage data events', () => { + const ws = makeWs(); + const orig = stubGlobalWebSocket(ws); + const onData = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData }); + + ws.onmessage!({ data: JSON.stringify({ type: 'next', payload: { value: 42 } }) }); + expect(onData).toHaveBeenCalledWith({ value: 42 }); + indexer.cleanup(); + (globalThis as any).WebSocket = orig; + }); + + it('handles data type events', () => { + const ws = makeWs(); + const orig = stubGlobalWebSocket(ws); + const onData = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData }); + + ws.onmessage!({ data: JSON.stringify({ type: 'data', data: { val: 'test' } }) }); + expect(onData).toHaveBeenCalledWith({ val: 'test' }); + indexer.cleanup(); + (globalThis as any).WebSocket = orig; + }); + + it('calls onError on error events', () => { + const ws = makeWs(); + const orig = stubGlobalWebSocket(ws); + const onError = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData: () => {}, onError }); + + ws.onmessage!({ data: JSON.stringify({ type: 'error', payload: 'something broke' }) }); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + indexer.cleanup(); + (globalThis as any).WebSocket = orig; + }); + + it('calls onError on socket onerror', () => { + const ws = makeWs(); + const orig = stubGlobalWebSocket(ws); + const onError = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData: () => {}, onError }); + + ws.onerror!({}); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + indexer.cleanup(); + (globalThis as any).WebSocket = orig; + }); + + it('ignores messages after unsubscribe', () => { + const ws = makeWs(); + const orig = stubGlobalWebSocket(ws); + const onData = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + const sub = indexer.subscribe({ query: 'sub { x }', onData }); + sub.unsubscribe(); + + ws.onmessage!({ data: JSON.stringify({ type: 'next', payload: { val: 1 } }) }); + expect(onData).not.toHaveBeenCalled(); + indexer.cleanup(); + (globalThis as any).WebSocket = orig; + }); + + it('ignores messages after indexer cleanup', () => { + const ws = makeWs(); + const orig = stubGlobalWebSocket(ws); + const onData = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData }); + indexer.cleanup(); + + ws.onmessage!({ data: JSON.stringify({ type: 'next', payload: { val: 1 } }) }); + expect(onData).not.toHaveBeenCalled(); + (globalThis as any).WebSocket = orig; + }); + + it('sends complete message on unsubscribe when socket is OPEN', () => { + const ws = makeWs(); + const orig = stubGlobalWebSocket(ws); + const indexer = new GraphQLIndexer(endpoint); + const sub = indexer.subscribe({ query: 'sub { x }', onData: () => {} }); + + ws.readyState = 1; + sub.unsubscribe(); + expect(ws.send).toHaveBeenCalledWith(expect.stringContaining('complete')); + indexer.cleanup(); + (globalThis as any).WebSocket = orig; + }); + + it('ignores socket close errors on unsubscribe', () => { + const ws = makeWs(); + const orig = stubGlobalWebSocket(ws); + ws.close = vi.fn(() => { throw new Error('close error'); }); + const indexer = new GraphQLIndexer(endpoint); + const sub = indexer.subscribe({ query: 'sub { x }', onData: () => {} }); + expect(() => sub.unsubscribe()).not.toThrow(); + indexer.cleanup(); + (globalThis as any).WebSocket = orig; + }); + + it('handles onmessage with object data (already parsed)', () => { + const ws = makeWs(); + const orig = stubGlobalWebSocket(ws); + const onData = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData }); + + ws.onmessage!({ data: { type: 'next', payload: { val: 1 } } }); + expect(onData).toHaveBeenCalledWith({ val: 1 }); + indexer.cleanup(); + (globalThis as any).WebSocket = orig; + }); + + it('ignores unknown message types', () => { + const ws = makeWs(); + const orig = stubGlobalWebSocket(ws); + const onData = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData }); + + ws.onmessage!({ data: JSON.stringify({ type: 'unknown' }) }); + expect(onData).not.toHaveBeenCalled(); + indexer.cleanup(); + (globalThis as any).WebSocket = orig; + }); + + it('ignores null data in onmessage', () => { + const ws = makeWs(); + const orig = stubGlobalWebSocket(ws); + const onData = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData }); + + ws.onmessage!({ data: JSON.stringify(null) }); + expect(onData).not.toHaveBeenCalled(); + indexer.cleanup(); + (globalThis as any).WebSocket = orig; + }); + + it('handles onmessage parse errors gracefully', () => { + const ws = makeWs(); + const orig = stubGlobalWebSocket(ws); + const onData = vi.fn(); + const onError = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData, onError }); + + ws.onmessage!({ data: 'not json {{{' }); + expect(onError).toHaveBeenCalled(); + indexer.cleanup(); + (globalThis as any).WebSocket = orig; + }); + + it('auto-unsubscribes on socket close when not already unsubscribed', () => { + const ws = makeWs(); + const orig = stubGlobalWebSocket(ws); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData: () => {} }); + + expect(indexer.getSubscriptionCount()).toBe(1); + ws.onclose!(); + expect(indexer.getSubscriptionCount()).toBe(0); + indexer.cleanup(); + (globalThis as any).WebSocket = orig; + }); + + it('does not auto-unsubscribe on close if already destroyed', () => { + const ws = makeWs(); + const orig = stubGlobalWebSocket(ws); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData: () => {} }); + indexer.cleanup(); + + ws.onclose!(); + expect(indexer.getSubscriptionCount()).toBe(0); + (globalThis as any).WebSocket = orig; + }); + + it('onopen does nothing if already unsubscribed', () => { + const ws = makeWs(); + const orig = stubGlobalWebSocket(ws); + const indexer = new GraphQLIndexer(endpoint); + const sub = indexer.subscribe({ query: 'sub { x }', onData: vi.fn() }); + + sub.unsubscribe(); + ws.send.mockClear(); + ws.onopen!(); + expect(ws.send).not.toHaveBeenCalled(); + indexer.cleanup(); + (globalThis as any).WebSocket = orig; + }); + + it('falls back to no-protocol WebSocket if protocol constructor throws', () => { + let callCount = 0; + const ws = makeWs(); + const orig = (globalThis as any).WebSocket; + const ctor = function (this: any, _url: string, _protocols?: string) { + callCount++; + if (arguments.length > 1) { + throw new Error('protocol not supported'); + } + return ws; + }; + ctor.prototype = {}; + (globalThis as any).WebSocket = ctor as any; + const onError = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + const sub = indexer.subscribe({ query: 'sub { x }', onData: () => {}, onError }); + expect(callCount).toBe(2); + sub.unsubscribe(); + indexer.cleanup(); + (globalThis as any).WebSocket = orig; + }); + + it('calls onError if socket.send throws in onopen', () => { + const ws = makeWs(); + ws.send = vi.fn().mockImplementation(() => { throw new Error('send failed'); }); + const orig = stubGlobalWebSocket(ws); + const onError = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData: () => {}, onError }); + ws.onopen!(); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + indexer.cleanup(); + (globalThis as any).WebSocket = orig; + }); + + it('calls onError if WebSocket constructor throws entirely', () => { + const orig = (globalThis as any).WebSocket; + const ctor = function () { + throw new Error('constructor failed'); + }; + ctor.prototype = {}; + (globalThis as any).WebSocket = ctor as any; + const onError = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData: () => {}, onError }); + expect(onError).toHaveBeenCalled(); + indexer.cleanup(); + (globalThis as any).WebSocket = orig; + }); + }); + + describe('HTTP SSE fallback path', () => { + beforeEach(() => { + (globalThis as any).WebSocket = undefined; + }); + + afterEach(() => { + (globalThis as any).WebSocket = globalThis.WebSocket; + }); + + it('calls onError when fetch rejects', async () => { + const origFetch = (globalThis as any).fetch; + (globalThis as any).fetch = vi.fn().mockRejectedValue(new Error('network down')); + const onError = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData: () => {}, onError }); + await new Promise(r => setTimeout(r, 50)); + expect(onError).toHaveBeenCalled(); + indexer.cleanup(); + (globalThis as any).fetch = origFetch; + }); + + it('calls onError on HTTP error response', async () => { + const origFetch = (globalThis as any).fetch; + (globalThis as any).fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 } as Response); + const onError = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData: () => {}, onError }); + await new Promise(r => setTimeout(r, 50)); + expect(onError).toHaveBeenCalled(); + indexer.cleanup(); + (globalThis as any).fetch = origFetch; + }); + + it('processes SSE data events', async () => { + const encoder = new TextEncoder(); + const reader = { + read: vi.fn() + .mockResolvedValueOnce({ value: encoder.encode('data: {"val":1}\n\n'), done: false }) + .mockResolvedValueOnce({ value: encoder.encode('data: {"val":2}\n\n'), done: false }) + .mockResolvedValue({ value: undefined, done: true }), + }; + const origFetch = (globalThis as any).fetch; + (globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, + body: { getReader: () => reader }, + } as unknown as Response); + const onData = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData }); + await new Promise(r => setTimeout(r, 50)); + expect(onData).toHaveBeenCalledTimes(2); + expect(onData).toHaveBeenCalledWith({ val: 1 }); + expect(onData).toHaveBeenCalledWith({ val: 2 }); + indexer.cleanup(); + (globalThis as any).fetch = origFetch; + }); + + it('skips SSE lines without data: prefix', async () => { + const encoder = new TextEncoder(); + const reader = { + read: vi.fn() + .mockResolvedValueOnce({ value: encoder.encode('event: test\ndata: {"val":1}\n\n'), done: false }) + .mockResolvedValue({ value: undefined, done: true }), + }; + const origFetch = (globalThis as any).fetch; + (globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, + body: { getReader: () => reader }, + } as unknown as Response); + const onData = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData }); + await new Promise(r => setTimeout(r, 50)); + expect(onData).toHaveBeenCalledTimes(1); + expect(onData).toHaveBeenCalledWith({ val: 1 }); + indexer.cleanup(); + (globalThis as any).fetch = origFetch; + }); + + it('handles malformed JSON in SSE stream gracefully', async () => { + const encoder = new TextEncoder(); + const reader = { + read: vi.fn() + .mockResolvedValueOnce({ value: encoder.encode('data: not json\n\ndata: {"val":2}\n\n'), done: false }) + .mockResolvedValue({ value: undefined, done: true }), + }; + const origFetch = (globalThis as any).fetch; + (globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, + body: { getReader: () => reader }, + } as unknown as Response); + const onData = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData }); + await new Promise(r => setTimeout(r, 50)); + expect(onData).toHaveBeenCalledTimes(1); + expect(onData).toHaveBeenCalledWith({ val: 2 }); + indexer.cleanup(); + (globalThis as any).fetch = origFetch; + }); + + it('stops on [DONE] in SSE stream', async () => { + const encoder = new TextEncoder(); + const reader = { + read: vi.fn() + .mockResolvedValueOnce({ value: encoder.encode('data: {"val":1}\n\ndata: [DONE]\n\ndata: {"val":2}\n\n'), done: false }) + .mockResolvedValue({ value: undefined, done: true }), + }; + const origFetch = (globalThis as any).fetch; + (globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, + body: { getReader: () => reader }, + } as unknown as Response); + const onData = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData }); + await new Promise(r => setTimeout(r, 50)); + expect(onData).toHaveBeenCalledTimes(1); + expect(onData).toHaveBeenCalledWith({ val: 1 }); + indexer.cleanup(); + (globalThis as any).fetch = origFetch; + }); + + it('exits SSE loop on stream done', async () => { + const encoder = new TextEncoder(); + const reader = { + read: vi.fn() + .mockResolvedValueOnce({ value: encoder.encode('data: {"val":1}\n\n'), done: false }) + .mockResolvedValue({ value: undefined, done: true }), + }; + const origFetch = (globalThis as any).fetch; + (globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, + body: { getReader: () => reader }, + } as unknown as Response); + const onData = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData }); + await new Promise(r => setTimeout(r, 50)); + expect(onData).toHaveBeenCalledTimes(1); + indexer.cleanup(); + (globalThis as any).fetch = origFetch; + }); + + it('unsubscribe aborts the fetch and does not crash', async () => { + const encoder = new TextEncoder(); + const reader = { + read: vi.fn() + .mockResolvedValueOnce({ value: encoder.encode('data: {"val":1}\n\n'), done: false }) + .mockResolvedValue({ value: undefined, done: true }), + }; + const origFetch = (globalThis as any).fetch; + (globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, + body: { getReader: () => reader }, + } as unknown as Response); + const onData = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + const sub = indexer.subscribe({ query: 'sub { x }', onData }); + await new Promise(r => setTimeout(r, 50)); + expect(onData).toHaveBeenCalledTimes(1); + expect(() => sub.unsubscribe()).not.toThrow(); + indexer.cleanup(); + (globalThis as any).fetch = origFetch; + }); + + it('does not enter SSE loop if response has no body reader', async () => { + const origFetch = (globalThis as any).fetch; + (globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, + body: null, + } as unknown as Response); + const onData = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'sub { x }', onData }); + await new Promise(r => setTimeout(r, 50)); + expect(onData).not.toHaveBeenCalled(); + indexer.cleanup(); + (globalThis as any).fetch = origFetch; + }); + }); + + describe('getWebSocketCtor', () => { + it('returns constructor from globalThis.WebSocket when available', () => { + const orig = (globalThis as any).WebSocket; + const fake = function () {}; + (globalThis as any).WebSocket = fake as any; + try { + const indexer = new GraphQLIndexer(endpoint); + const ctor = (indexer as any).getWebSocketCtor(); + expect(ctor).toBe(fake); + indexer.cleanup(); + } finally { + (globalThis as any).WebSocket = orig; + } + }); + + it('returns null when WebSocket is not available', () => { + const orig = (globalThis as any).WebSocket; + (globalThis as any).WebSocket = undefined; + try { + const indexer = new GraphQLIndexer(endpoint); + const ctor = (indexer as any).getWebSocketCtor(); + expect(ctor).toBeNull(); + indexer.cleanup(); + } finally { + (globalThis as any).WebSocket = orig; + } + }); + }); + + describe('getWsUrl', () => { + it('converts https to wss', () => { + const indexer = new GraphQLIndexer('https://example.com/graphql'); + expect((indexer as any).getWsUrl('https://example.com/graphql')).toBe('wss://example.com/graphql'); + indexer.cleanup(); + }); + + it('converts http to ws', () => { + const indexer = new GraphQLIndexer('http://example.com/graphql'); + expect((indexer as any).getWsUrl('http://example.com/graphql')).toBe('ws://example.com/graphql'); + indexer.cleanup(); + }); + + it('returns endpoint as-is for unknown protocol', () => { + const indexer = new GraphQLIndexer('ws://example.com/graphql'); + expect((indexer as any).getWsUrl('ws://example.com/graphql')).toBe('ws://example.com/graphql'); + indexer.cleanup(); + }); + }); + + describe('handleError', () => { + it('calls onError with Error instance', () => { + const indexer = new GraphQLIndexer(endpoint); + const onError = vi.fn(); + (indexer as any).handleError(onError, 'string error'); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + + (indexer as any).handleError(onError, new Error('real error')); + expect(onError).toHaveBeenCalledWith(new Error('real error')); + indexer.cleanup(); + }); + + it('does not throw if onError throws', () => { + const indexer = new GraphQLIndexer(endpoint); + const onError = vi.fn().mockImplementation(() => { throw new Error('onError blew up'); }); + expect(() => (indexer as any).handleError(onError, 'test')).not.toThrow(); + indexer.cleanup(); + }); + + it('does nothing if onError is not a function', () => { + const indexer = new GraphQLIndexer(endpoint); + expect(() => (indexer as any).handleError(null, 'test')).not.toThrow(); + expect(() => (indexer as any).handleError(undefined, 'test')).not.toThrow(); + indexer.cleanup(); + }); + }); + }); +}); diff --git a/src/tests/network-switcher-validation.test.ts b/src/tests/network-switcher-validation.test.ts index 0d50eef..58e41bf 100644 --- a/src/tests/network-switcher-validation.test.ts +++ b/src/tests/network-switcher-validation.test.ts @@ -22,7 +22,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { UnsupportedChainError } from '../errors.js'; +import type { UnsupportedChainError } from '../errors.js'; import { WalletConnectAdapter } from '../adapters/walletconnect.js'; // ── Mock soroban so FactoryModule / GovernorModule can be imported without @@ -98,6 +98,14 @@ describe('ConduitClient — constructor network validation', () => { // ── 2. ConduitClient.setWallet() — cross-chain wallet mismatch (was bypass) ── +function unknownChainWallet(): import('../adapters/types.js').WalletAdapter { + return { + getPublicKey: () => 'G...', + signTransaction: async (tx) => tx as any, + chainId: 'stellar:unknown', + }; +} + describe('ConduitClient.setWallet() — network cross-validation (#157)', () => { beforeEach(() => vi.resetModules()); @@ -133,6 +141,12 @@ describe('ConduitClient.setWallet() — network cross-validation (#157)', () => expect(() => client.setWallet(testnetWallet)).not.toThrow(); }); + it('rejects a wallet with an unrecognisable chainId', async () => { + const { ConduitClient } = await import('../client.js'); + const client = new ConduitClient({ network: 'testnet', factoryAddress: FACTORY_ADDR }); + expect(() => client.setWallet(unknownChainWallet())).toThrow(/Unsupported network/); + }); + it('accepts a KeypairWalletAdapter (no chainId) on any network — chain-agnostic adapters are always allowed', async () => { const { ConduitClient } = await import('../client.js'); const { KeypairWalletAdapter } = await import('../adapters/keypair.js'); diff --git a/src/tests/nonce-concurrent.test.ts b/src/tests/nonce-concurrent.test.ts index 9f72917..4425a84 100644 --- a/src/tests/nonce-concurrent.test.ts +++ b/src/tests/nonce-concurrent.test.ts @@ -144,4 +144,38 @@ describe('NonceManager — Concurrent Nonce Integration Tests', () => { expect(lock3.nonce).toBe(0n); lock3.release(); }); + + it('clamps maxNonce to MAX_SAFE_U64', () => { + const m = new NonceManager({ startNonce: 0n, maxNonce: 100000000000000000000n }); + expect(m.remaining).toBe(18446744073709551615n); + m.destroy(); + }); + + it('falls back to 0n for invalid string nonce', () => { + const m = new NonceManager({ startNonce: 'invalid', maxNonce: '100' }); + expect(m.current).toBe(0n); + m.destroy(); + }); + + it('queues acquire when locked', async () => { + const m = new NonceManager({ startNonce: 0n, maxNonce: 100n }); + const lock1 = await m.acquire(); + let lock2: NonceLock | undefined; + const acquirePromise = m.acquire().then(l => { lock2 = l; }); + await new Promise(r => setTimeout(r, 0)); + expect((m as any).lockQueue.length).toBe(1); + lock1.release(); + await acquirePromise; + expect(lock2!.nonce).toBe(1n); + lock2!.release(); + m.destroy(); + }); + + it('acquireWithFallback times out', async () => { + const m = new NonceManager({ startNonce: 0n, maxNonce: 100n }); + const lock = await m.acquire(); + await expect(m.acquireWithFallback(10)).rejects.toThrow('timed out'); + lock.release(); + m.destroy(); + }); }); \ No newline at end of file diff --git a/src/tests/signer-edge.test.ts b/src/tests/signer-edge.test.ts new file mode 100644 index 0000000..4b4baba --- /dev/null +++ b/src/tests/signer-edge.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Keypair } from '@stellar/stellar-sdk'; + +describe('KeypairSigner', () => { + it('signs a transaction', async () => { + const kp = Keypair.random(); + const { KeypairSigner } = await import('../signer.js'); + const signer = new KeypairSigner(kp); + const tx = { sign: vi.fn() } as any; + signer.sign(tx); + expect(tx.sign).toHaveBeenCalledWith(kp); + }); + + it('returns public key', async () => { + const kp = Keypair.random(); + const { KeypairSigner } = await import('../signer.js'); + const signer = new KeypairSigner(kp); + expect(signer.publicKey()).toBe(kp.publicKey()); + }); +}); + +describe('TransactionSigner', () => { + it('throws on _signTypedData after destroy', async () => { + const { TransactionSigner } = await import('../signer.js'); + const signer = new TransactionSigner(); + signer.cleanup(); + await expect( + (signer as any)._signTypedData({}, {}, {}), + ).rejects.toThrow('TransactionSigner has been destroyed'); + }); + + it('throws on signProposal after destroy', async () => { + const { TransactionSigner } = await import('../signer.js'); + const signer = new TransactionSigner(); + signer.cleanup(); + await expect( + signer.signProposal([]), + ).rejects.toThrow('TransactionSigner has been destroyed'); + }); + + it('publicKey returns mock key', async () => { + const { TransactionSigner } = await import('../signer.js'); + const signer = new TransactionSigner(); + expect(signer.publicKey()).toBe('GTRANSACTIONSIGNERMOCKKEY'); + }); + + it('rejects when wallet adapter returns null tx', async () => { + const { TransactionSigner } = await import('../signer.js'); + const signer = new TransactionSigner({ + walletAdapter: { + getPublicKey: () => 'G...', + signTransaction: async () => null as any, + }, + }); + await expect(signer.sign({} as any)).rejects.toThrow('null or undefined'); + }); + + it('rejects when sign throws', async () => { + const { TransactionSigner } = await import('../signer.js'); + const signer = new TransactionSigner({ + walletAdapter: { + getPublicKey: () => 'G...', + signTransaction: async () => { throw new Error('wallet error'); }, + }, + }); + await expect(signer.sign({} as any)).rejects.toThrow('wallet error'); + }); +}); diff --git a/src/tests/soroban-cache.test.ts b/src/tests/soroban-cache.test.ts new file mode 100644 index 0000000..ee3dba5 --- /dev/null +++ b/src/tests/soroban-cache.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SorobanRpc } from '@stellar/stellar-sdk'; +import { getServer, clearServerCache } from '../soroban.js'; + +describe('SorobanRpc.Server caching', () => { + beforeEach(() => clearServerCache()); + afterEach(() => clearServerCache()); + + it('returns the same instance for the same URL', () => { + const s1 = getServer('https://soroban-testnet.stellar.org'); + const s2 = getServer('https://soroban-testnet.stellar.org'); + expect(s1).toBe(s2); + }); + + it('returns different instances for different URLs', () => { + const s1 = getServer('https://soroban-testnet.stellar.org'); + const s2 = getServer('https://soroban-mainnet.stellar.org'); + expect(s1).not.toBe(s2); + }); + + it('creates server with allowHttp for http:// URLs', () => { + const s = getServer('http://localhost:8000/soroban/rpc'); + expect(s).toBeDefined(); + }); + + it('clears cache on clearServerCache', () => { + const s1 = getServer('https://soroban-testnet.stellar.org'); + clearServerCache(); + const s2 = getServer('https://soroban-testnet.stellar.org'); + expect(s1).not.toBe(s2); + }); +}); diff --git a/src/tests/soroban-invoke.test.ts b/src/tests/soroban-invoke.test.ts new file mode 100644 index 0000000..ce2ecd7 --- /dev/null +++ b/src/tests/soroban-invoke.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { + mockSimulateTransaction, + mockSendTransaction, + mockGetTransaction, + mockAssembleTransaction, +} = vi.hoisted(() => ({ + mockSimulateTransaction: vi.fn(), + mockSendTransaction: vi.fn(), + mockGetTransaction: vi.fn(), + mockAssembleTransaction: vi.fn(), +})); + +vi.mock('@stellar/stellar-sdk', async () => { + const actual = await vi.importActual('@stellar/stellar-sdk'); + return { + ...actual, + SorobanRpc: { + ...(actual as any).SorobanRpc, + Server: vi.fn().mockImplementation(function MockServer() { + return { + simulateTransaction: mockSimulateTransaction, + sendTransaction: mockSendTransaction, + getTransaction: mockGetTransaction, + }; + }), + Api: (actual as any).SorobanRpc.Api, + assembleTransaction: mockAssembleTransaction, + }, + }; +}); + +import { invokeContract } from '../soroban.js'; +import type { Signer } from '../signer.js'; + +function makeSigner(): Signer { + return { + sign: vi.fn().mockResolvedValue(undefined), + publicKey: () => 'GABC', + }; +} + +describe('invokeContract — submission error paths', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAssembleTransaction.mockReturnValue({ build: vi.fn().mockReturnValue({}) }); + }); + + it('handles sendTransaction ERROR status', async () => { + mockSimulateTransaction.mockResolvedValue({ + result: { retval: { toXDR: () => Buffer.from('') } }, + } as any); + mockSendTransaction.mockResolvedValue({ + status: 'ERROR', + errorResult: { message: 'bad' }, + }); + + await expect( + invokeContract('http://localhost:8000', 'testnet', makeSigner(), {} as any), + ).rejects.toThrow('Transaction rejected'); + }); + + it('handles getTransaction FAILED status', async () => { + mockSimulateTransaction.mockResolvedValue({ + result: { retval: { toXDR: () => Buffer.from('') } }, + } as any); + mockSendTransaction.mockResolvedValue({ status: 'PENDING', hash: '0'.repeat(64) }); + mockGetTransaction.mockResolvedValue({ status: 'FAILED' }); + + await expect( + invokeContract('http://localhost:8000', 'testnet', makeSigner(), {} as any), + ).rejects.toThrow('Transaction failed'); + }); + + it('handles simulation error', async () => { + mockSimulateTransaction.mockResolvedValue({ error: 'simulation error' } as any); + + await expect( + invokeContract('http://localhost:8000', 'testnet', makeSigner(), {} as any), + ).rejects.toThrow('Simulation failed'); + }); + + it('handles sendTransaction throw', async () => { + mockSimulateTransaction.mockResolvedValue({ + result: { retval: { toXDR: () => Buffer.from('') } }, + } as any); + mockSendTransaction.mockRejectedValue(new Error('network error')); + + await expect( + invokeContract('http://localhost:8000', 'testnet', makeSigner(), {} as any), + ).rejects.toThrow('network error'); + }); + + it('handles getTransaction throw', async () => { + mockSimulateTransaction.mockResolvedValue({ + result: { retval: { toXDR: () => Buffer.from('') } }, + } as any); + mockSendTransaction.mockResolvedValue({ status: 'PENDING', hash: '0'.repeat(64) }); + mockGetTransaction.mockRejectedValue(new Error('get error')); + + await expect( + invokeContract('http://localhost:8000', 'testnet', makeSigner(), {} as any), + ).rejects.toThrow('get error'); + }); + + it('handles simulation throw', async () => { + mockSimulateTransaction.mockRejectedValue(new Error('sim error')); + + await expect( + invokeContract('http://localhost:8000', 'testnet', makeSigner(), {} as any), + ).rejects.toThrow('sim error'); + }); + + it('returns hash on getTransaction SUCCESS', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + mockSimulateTransaction.mockResolvedValue({ + result: { retval: { toXDR: () => Buffer.from('') } }, + } as any); + mockSendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'abcd1234' }); + mockGetTransaction.mockResolvedValue({ status: 'SUCCESS' }); + + const promise = invokeContract( + 'http://localhost:8000', 'testnet', makeSigner(), {} as any, + { maxAttempts: 5, pollIntervalMs: 10 }, + ); + vi.advanceTimersByTimeAsync(10); + const result = await promise; + expect(result).toBe('abcd1234'); + vi.useRealTimers(); + }); + + it('throws on polling timeout', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + mockSimulateTransaction.mockResolvedValue({ + result: { retval: { toXDR: () => Buffer.from('') } }, + } as any); + mockSendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'deadbeef' }); + mockGetTransaction.mockResolvedValue({ status: 'NOT_FOUND' }); + + const promise = invokeContract( + 'http://localhost:8000', 'testnet', makeSigner(), {} as any, + { maxAttempts: 2, pollIntervalMs: 10 }, + ); + vi.advanceTimersByTimeAsync(10); + await expect(promise).rejects.toThrow('timed out'); + vi.useRealTimers(); + }); +}); diff --git a/src/tests/streams-edge-cases.test.ts b/src/tests/streams-edge-cases.test.ts new file mode 100644 index 0000000..263ffa9 --- /dev/null +++ b/src/tests/streams-edge-cases.test.ts @@ -0,0 +1,397 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { xdr, Keypair, nativeToScVal, Address } from '@stellar/stellar-sdk'; + +const { + mockSimulateTransaction, + mockGetAccount, + mockSendTransaction, + mockGetTransaction, + mockAssembleTransaction, +} = vi.hoisted(() => ({ + mockSimulateTransaction: vi.fn(), + mockGetAccount: vi.fn(), + mockSendTransaction: vi.fn(), + mockGetTransaction: vi.fn(), + mockAssembleTransaction: vi.fn(), +})); + +const VALID_CONTRACT = (() => { + const { StrKey } = require('@stellar/stellar-sdk'); + const buf = Buffer.alloc(32); + require('crypto').randomFillSync(buf); + return StrKey.encodeContract(buf); +})(); + +vi.mock('@stellar/stellar-sdk', async () => { + const actual = await vi.importActual('@stellar/stellar-sdk'); + return { + ...actual, + SorobanRpc: { + ...(actual as any).SorobanRpc, + Server: vi.fn().mockImplementation(function MockServer() { + return { + getAccount: mockGetAccount, + simulateTransaction: mockSimulateTransaction, + sendTransaction: mockSendTransaction, + getTransaction: mockGetTransaction, + }; + }), + Api: (actual as any).SorobanRpc.Api, + assembleTransaction: mockAssembleTransaction, + }, + }; +}); + +vi.mock('../factory.js', () => { + return { + FactoryModule: class { + streamAddress = vi.fn().mockResolvedValue(VALID_CONTRACT); + streamCount = vi.fn().mockResolvedValue(42n); + streamsBySender = vi.fn().mockResolvedValue([1n, 2n, 3n]); + streamsByRecipient = vi.fn().mockResolvedValue([1n, 2n]); + protocolFeeBps = vi.fn().mockResolvedValue(30); + } as any, + }; +}); + +import { SorobanRpc } from '@stellar/stellar-sdk'; +import { StreamsModule } from '../streams.js'; +import type { ConduitConfig } from '../types/index.js'; + +describe('StreamsModule — edge cases', () => { + let config: ConduitConfig; + + beforeEach(() => { + vi.clearAllMocks(); + const { Account, Keypair } = require('@stellar/stellar-sdk'); + const pk = Keypair.random().publicKey(); + mockGetAccount.mockResolvedValue(new Account(pk, '1')); + mockSimulateTransaction.mockResolvedValue({ + result: { retval: { toXDR: () => xdr.ScVal.scvMap([]).toXDR() } }, + }); + mockSendTransaction.mockResolvedValue({ status: 'PENDING', hash: '0'.repeat(64) }); + mockGetTransaction.mockResolvedValue({ status: 'SUCCESS', returnValue: xdr.ScVal.scvVoid() }); + mockAssembleTransaction.mockReturnValue({ build: vi.fn().mockReturnValue({ sign: vi.fn() }) }); + + config = { + network: 'testnet', + factoryAddress: VALID_CONTRACT, + keypair: { + publicKey: () => 'GBOGNMY4GQI7S6K5C5B4PH2FWZ3K2GXN4AAAAA', + sign: vi.fn(), + } as any, + }; + }); + + describe('_sendAndPoll', () => { + it('handles ERROR status from sendTransaction', async () => { + mockSendTransaction.mockResolvedValue({ + status: 'ERROR', + errorResult: { message: 'insufficient fee' }, + }); + + const streams = new StreamsModule(config); + const server = new SorobanRpc.Server('http://localhost:8000'); + const tx = { toXDR: () => '' } as any; + await expect((streams as any)._sendAndPoll(server, tx)).rejects.toThrow( + 'Transaction rejected', + ); + }); + + it('handles FAILED status from getTransaction', async () => { + mockSendTransaction.mockResolvedValue({ status: 'PENDING', hash: '0'.repeat(64) }); + mockGetTransaction.mockResolvedValue({ status: 'FAILED' }); + + const streams = new StreamsModule(config); + const server = new SorobanRpc.Server('http://localhost:8000'); + const tx = { toXDR: () => '' } as any; + await expect((streams as any)._sendAndPoll(server, tx)).rejects.toThrow( + 'Transaction failed', + ); + }); + + it('continues polling on NOT_FOUND', async () => { + let callCount = 0; + mockGetTransaction.mockImplementation(() => { + callCount++; + if (callCount < 3) return { status: 'NOT_FOUND' }; + return { status: 'SUCCESS', returnValue: xdr.ScVal.scvVoid() }; + }); + + const streams = new StreamsModule(config); + const server = new SorobanRpc.Server('http://localhost:8000'); + const tx = { toXDR: () => '' } as any; + const result = await (streams as any)._sendAndPoll(server, tx); + expect(result.hash).toBe('0'.repeat(64)); + }); + + it('throws on transaction timeout', async () => { + mockGetTransaction.mockResolvedValue({ status: 'NOT_FOUND' }); + + const streams = new StreamsModule({ + ...config, + confirmationMaxAttempts: 2, + confirmationPollIntervalMs: 1, + }); + const server = new SorobanRpc.Server('http://localhost:8000'); + const tx = { toXDR: () => '' } as any; + await expect((streams as any)._sendAndPoll(server, tx)).rejects.toThrow( + 'Transaction timed out', + ); + }); + }); + + describe('_signerPublicKey fallbacks', () => { + it('uses keypair when no wallet or signer', () => { + const streams = new StreamsModule(config); + expect((streams as any)._signerPublicKey()).toBe(config.keypair!.publicKey()); + }); + + it('uses wallet public key', () => { + const wallet = { getPublicKey: () => 'GWALLET123' }; + const streams = new StreamsModule({ ...config, wallet: wallet as any }); + expect((streams as any)._signerPublicKey()).toBe('GWALLET123'); + }); + + it('uses signer when no wallet', () => { + const cfg = { ...config }; + delete cfg.keypair; + const streams = new StreamsModule({ ...cfg, signer: { sign: vi.fn(), publicKey: () => 'GSIGNER' } as any }); + expect((streams as any)._signerPublicKey()).toBe('GSIGNER'); + }); + }); + + describe('_resolveCallerAddress', () => { + it('falls back to keypair when wallet returns null', async () => { + const wallet = { getPublicKey: vi.fn().mockResolvedValue(null) }; + const streams = new StreamsModule({ ...config, wallet: wallet as any }); + const addr = await (streams as any)._resolveCallerAddress(); + expect(addr).toBe(config.keypair!.publicKey()); + }); + + it('returns ZERO_ADDR with no auth', async () => { + const bareConfig: ConduitConfig = { network: 'testnet', factoryAddress: VALID_CONTRACT }; + const streams = new StreamsModule(bareConfig); + const addr = await (streams as any)._resolveCallerAddress(); + expect(addr).toBe('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'); + }); + + it('uses wallet public key', async () => { + const wallet = { getPublicKey: vi.fn().mockResolvedValue('GWALLET') }; + const streams = new StreamsModule({ ...config, wallet: wallet as any }); + const addr = await (streams as any)._resolveCallerAddress(); + expect(addr).toBe('GWALLET'); + }); + }); + + describe('_ensureCanMutate', () => { + it('throws without auth', () => { + const bareConfig: ConduitConfig = { network: 'testnet', factoryAddress: VALID_CONTRACT }; + const streams = new StreamsModule(bareConfig); + expect(() => (streams as any)._ensureCanMutate()).toThrow( + 'keypair, wallet adapter, or signer is required for mutating operations', + ); + }); + + it('succeeds with keypair', () => { + const streams = new StreamsModule(config); + expect(() => (streams as any)._ensureCanMutate()).not.toThrow(); + }); + }); + + describe('get() simulation failure', () => { + it('throws on simulation error', async () => { + mockSimulateTransaction.mockResolvedValue({ error: 'HostError: Error(Contract, #6)' }); + const streams = new StreamsModule(config); + await expect(streams.get(1n)).rejects.toThrow(); + }); + }); + + describe('list()', () => { + it('returns empty when no sender/recipient', async () => { + const streams = new StreamsModule(config); + const result = await streams.list({}); + expect(result.streams).toHaveLength(0); + expect(result.hasNextPage).toBe(false); + }); + + it('lists by sender', async () => { + const streams = new StreamsModule(config); + const result = await streams.list({ sender: 'GSENDER' }); + expect(result.streams).toHaveLength(3); + }); + + it('lists by recipient', async () => { + const streams = new StreamsModule(config); + const result = await streams.list({ recipient: 'GRECIP' }); + expect(result.streams).toHaveLength(2); + }); + }); + + describe('batchWithdraw', () => { + it('reports per-item errors without throwing', async () => { + const streams = new StreamsModule(config); + vi.spyOn(streams, 'withdraw' as any) + .mockRejectedValueOnce(new Error('Stream cancelled')) + .mockResolvedValueOnce('txhash1'); + + const results = await streams.batchWithdraw([ + { streamId: 1n }, + { streamId: 2n }, + ]); + expect(results).toHaveLength(2); + expect(results[0].success).toBe(false); + expect(results[0].error).toBe('Stream cancelled'); + expect(results[1].success).toBe(true); + expect(results[1].txHash).toBe('txhash1'); + }); + }); + + describe('withdrawable', () => { + it('returns the withdrawable amount', async () => { + const streams = new StreamsModule(config); + mockSimulateTransaction.mockResolvedValue({ + result: { + retval: { toXDR: () => nativeToScVal(500n, { type: 'i128' }).toXDR() }, + }, + } as any); + const amount = await streams.withdrawable(1n); + expect(amount).toBe(500n); + }); + }); + + describe('list() — cursor/invalid', () => { + it('throws on invalid cursor', async () => { + const streams = new StreamsModule(config); + await expect(streams.list({ cursor: '!!!' })).rejects.toThrow('Invalid cursor'); + }); + + it('throws on non-numeric cursor', async () => { + const streams = new StreamsModule(config); + const badCursor = Buffer.from('not_a_number', 'utf8').toString('base64'); + await expect(streams.list({ cursor: badCursor })).rejects.toThrow('Invalid cursor'); + }); + }); + + describe('create — validation', () => { + it('throws on invalid recipient', async () => { + const streams = new StreamsModule(config); + await expect(streams.create({ + recipient: '', + token: 'CAFEFACE', + depositAmount: '100', + ratePerSecond: '1', + } as any)).rejects.toThrow('Invalid recipient address'); + }); + + it('throws on invalid token', async () => { + const streams = new StreamsModule(config); + await expect(streams.create({ + recipient: 'GRECIP', + token: '', + depositAmount: '100', + ratePerSecond: '1', + } as any)).rejects.toThrow('Invalid token address'); + }); + + it('throws on invalid depositAmount', async () => { + const streams = new StreamsModule(config); + await expect(streams.create({ + recipient: 'GRECIP', + token: 'CAFEFACE', + depositAmount: '', + ratePerSecond: '1', + } as any)).rejects.toThrow('Invalid deposit amount'); + }); + + it('throws on invalid durationSeconds', async () => { + const streams = new StreamsModule(config); + await expect(streams.create({ + recipient: 'GRECIP', + token: 'CAFEFACE', + depositAmount: '100', + durationSeconds: 0, + } as any)).rejects.toThrow('Invalid durationSeconds'); + }); + + it('throws on invalid ratePerSecond', async () => { + const streams = new StreamsModule(config); + await expect(streams.create({ + recipient: 'GRECIP', + token: 'CAFEFACE', + depositAmount: '100', + ratePerSecond: '', + } as any)).rejects.toThrow('Invalid ratePerSecond'); + }); + + it('throws when neither duration nor rate provided', async () => { + const streams = new StreamsModule(config); + await expect(streams.create({ + recipient: 'GRECIP', + token: 'CAFEFACE', + depositAmount: '100', + } as any)).rejects.toThrow('durationSeconds or ratePerSecond'); + }); + }); + + describe('_signer returns null', () => { + it('returns null when no signer configured', () => { + const streams = new StreamsModule(config); + expect((streams as any)._signer()).toBeNull(); + }); + }); + + describe('_getSenderAddress', () => { + it('returns signer publicKey when no wallet', async () => { + const { StrKey, Keypair } = await import('@stellar/stellar-sdk'); + const buf = Buffer.alloc(32); + require('crypto').randomFillSync(buf); + const contractId = StrKey.encodeContract(buf); + const signerPk = Keypair.random().publicKey(); + const streams = new StreamsModule({ + network: 'testnet', + factoryAddress: contractId, + signer: { sign: vi.fn(), publicKey: () => signerPk }, + } as any); + (streams as any).activeWallet = undefined; + const addr = await (streams as any)._getSenderAddress(); + expect(addr).toBe(signerPk); + }); + + it('returns keypair publicKey when no wallet or signer', async () => { + const { StrKey, Keypair } = await import('@stellar/stellar-sdk'); + const buf = Buffer.alloc(32); + require('crypto').randomFillSync(buf); + const contractId = StrKey.encodeContract(buf); + const kp = Keypair.random(); + const streams = new StreamsModule({ + network: 'testnet', + factoryAddress: contractId, + keypair: kp, + } as any); + (streams as any).activeWallet = undefined; + const addr = await (streams as any)._getSenderAddress(); + expect(addr).toBe(kp.publicKey()); + }); + }); + + describe('_signTx with keypair', () => { + it('signs with keypair when no wallet or signer', async () => { + const { StrKey, Keypair, Transaction } = await import('@stellar/stellar-sdk'); + const buf = Buffer.alloc(32); + require('crypto').randomFillSync(buf); + const contractId = StrKey.encodeContract(buf); + const kp = Keypair.random(); + const streams = new StreamsModule({ + network: 'testnet', + factoryAddress: contractId, + keypair: kp, + } as any); + (streams as any).activeWallet = undefined; + const mockTx = { sign: vi.fn(), toXDR: () => '' } as any; + const result = await (streams as any)._signTx(mockTx); + expect(mockTx.sign).toHaveBeenCalledWith(kp); + expect(result).toBe(mockTx); + }); + }); +}); diff --git a/src/tests/walletconnect.test.ts b/src/tests/walletconnect.test.ts index 78a06a1..19a5edf 100644 --- a/src/tests/walletconnect.test.ts +++ b/src/tests/walletconnect.test.ts @@ -193,4 +193,37 @@ describe('KeypairWalletAdapter', () => { expect(signedTx).toBeInstanceOf(Transaction); expect((signedTx as Transaction).signatures.length).toBe(1); }); + + it('signs a raw XDR string with network passphrase', async () => { + const keypair = Keypair.random(); + const adapter = new KeypairWalletAdapter(keypair); + + const account = new Account(keypair.publicKey(), '100'); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: Networks.TESTNET, + }) + .setTimeout(30) + .build(); + const xdr = tx.toXDR(); + + const signed = await adapter.signTransaction(xdr, { networkPassphrase: Networks.TESTNET }); + expect(typeof signed).toBe('string'); + }); + + it('throws when signing raw XDR without networkPassphrase', async () => { + const keypair = Keypair.random(); + const adapter = new KeypairWalletAdapter(keypair); + + const account = new Account(keypair.publicKey(), '100'); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: Networks.TESTNET, + }) + .setTimeout(30) + .build(); + const xdr = tx.toXDR(); + + await expect(adapter.signTransaction(xdr)).rejects.toThrow('networkPassphrase'); + }); }); diff --git a/src/types/index.ts b/src/types/index.ts index 94ff93e..873cb7f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,6 +2,18 @@ import type { Signer } from '../signer.js'; export type Network = 'mainnet' | 'testnet' | 'local'; +/** Configuration returned by StreamBuilder.build() */ +export interface StreamConfig { + token: string; + sender: string; + recipient: string; + amount: number; + ratePerSecond?: string; +} + +/** A Stellar account address (G... string) */ +export type Recipient = string; + export interface ConduitConfig { /** Network to connect to */ network: Network; @@ -46,6 +58,9 @@ export interface StreamInfo { clawbackEnabled: boolean; } +/** On-chain state of a stream — alias for StreamInfo */ +export type StreamState = StreamInfo; + export interface CreateStreamParams { /** Stellar recipient address */ recipient: string; diff --git a/src/utils.ts b/src/utils.ts index ca56990..622b23b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,22 +1,49 @@ import type { StreamInfo } from './types/index.js'; import { StrKey } from '@stellar/stellar-sdk'; -/** Convert a display amount string to stroops (bigint) */ +const _powCache: Record = {}; + +function _pow10(decimals: number): bigint { + let p = _powCache[decimals]; + if (!p) { + p = BigInt(10) ** BigInt(decimals); + _powCache[decimals] = p; + } + return p; +} + +/** Convert a display amount string to stroops (bigint). + * + * Cache-friendly — `10 ** decimals` is computed once per unique `decimals` + * value and reused across calls, avoiding repeated BigInt exponentiation. + */ export function toStroops(amount: string, decimals = 7): bigint { - const [whole = '0', frac = ''] = amount.split('.'); - const fracPadded = frac.slice(0, decimals + 1).padEnd(decimals + 1, '0'); - const fracValue = BigInt(fracPadded); - const roundingDigit = Number(fracPadded[decimals] ?? '0'); - const roundedFrac = roundingDigit >= 5 ? fracValue / 10n + 1n : fracValue / 10n; - return BigInt(whole) * BigInt(10 ** decimals) + roundedFrac; + const dot = amount.indexOf('.'); + if (dot === -1) { + return BigInt(amount) * _pow10(decimals); + } + const whole = amount.slice(0, dot) || '0'; + const frac = amount.slice(dot + 1, dot + 1 + decimals); + const rest = amount.slice(dot + 1 + decimals); + + let base = BigInt(whole) * _pow10(decimals) + BigInt(frac.padEnd(decimals, '0')); + if (rest) { + const roundingDigit = rest.charCodeAt(0) /* '0' … '9' */; + if (roundingDigit >= 53 /* charCode of '5' */) { + base += 1n; + } + } + return base; } /** Convert stroops (bigint) to a display amount string */ export function fromStroops(stroops: bigint, decimals = 7): string { - const factor = BigInt(10 ** decimals); - const whole = stroops / factor; - const frac = (stroops % factor).toString().padStart(decimals, '0'); - const trimmed = frac.replace(/0+$/, '') || '0'; + const factor = _pow10(decimals); + const whole = stroops / factor; + const frac = (stroops % factor).toString().padStart(decimals, '0'); + let end = frac.length; + while (end > 0 && frac.charCodeAt(end - 1) === 48 /* '0' */) end--; + const trimmed = end === 0 ? '0' : frac.slice(0, end); return `${whole}.${trimmed}`; } @@ -81,23 +108,41 @@ export function withdrawableLocal(stream: StreamInfo, nowSec = Math.floor(Date.n * `JSON.stringify`, which breaks payloads sent to the GraphQL * indexer. Call this before network submission to guarantee * interoperability across all browsers. + * + * Uses a `WeakSet` to handle circular references without infinite + * recursion, and iterates with indexed loops instead of `.map()` / + * `.entries()` to minimise per-element allocations. */ export function bigintSafeStringify(value: T): T { + return _safeStringify(value, new WeakSet()); +} + +function _safeStringify(value: T, visited: WeakSet): T { if (typeof value === 'bigint') { // eslint-disable-next-line @typescript-eslint/no-explicit-any return value.toString() as any; } + if (value === null || typeof value !== 'object') return value; + if (visited.has(value)) return value as T; + visited.add(value); + if (Array.isArray(value)) { - return value.map(bigintSafeStringify) as unknown as T; - } - if (value !== null && typeof value === 'object') { - const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) { - out[k] = bigintSafeStringify(v); + const { length } = value; + const out = new Array(length); + for (let i = 0; i < length; i++) { + out[i] = _safeStringify(value[i], visited); } - return out as T; + return out as unknown as T; + } + + const obj = value as Record; + const keys = Object.keys(obj); + const out: Record = {}; + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + out[k] = _safeStringify(obj[k], visited); } - return value; + return out as T; } /**