diff --git a/.github/workflows/npmPublish.yml b/.github/workflows/npmPublish.yml new file mode 100644 index 0000000..508f8fd --- /dev/null +++ b/.github/workflows/npmPublish.yml @@ -0,0 +1,22 @@ +name: npm publish +on: + release: + types: [published] +jobs: + npm-publish: + name: npm-publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: 18 + registry-url: https://registry.npmjs.org/ + - run: yarn install + - run: yarn build + - run: yarn test:ci + + - name: Publish to NPM + run: yarn publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/src/index.ts b/src/index.ts index 9e44750..74d009e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,12 @@ export { Utils }; import * as Exceptions from "./walletSdk/Exceptions"; export { Exceptions }; +/** + * Server + */ +import * as Server from "./walletSdk/Server"; +export { Server }; + import * as walletSdk from "./walletSdk"; import { Keypair } from "@stellar/stellar-sdk"; // TODO - figure out why Keypair used in parent codebase throws error diff --git a/src/walletSdk/Anchor/Sep24.ts b/src/walletSdk/Anchor/Sep24.ts index 5ddf066..db57215 100644 --- a/src/walletSdk/Anchor/Sep24.ts +++ b/src/walletSdk/Anchor/Sep24.ts @@ -61,6 +61,10 @@ export class Sep24 { * @param {ExtraFields} [params.extraFields] - Additional fields for the request. * @param {Memo} [params.destinationMemo] - Memo information for the destination account. * @param {string} [params.destinationAccount] - The destination account for the deposit. + * @param {string} [params.callback] - The callback URL the anchor should POST to + * on a successfully completed interactive flow. + * @param {string} [params.on_change_callback] - The URL the anchor should POST to + * when the 'status' or 'kyc_verified' properties change. * @returns {Promise} The Sep24 response. * @throws {AssetNotSupportedError} If the asset is not supported for deposit. */ @@ -71,6 +75,8 @@ export class Sep24 { extraFields, destinationMemo, destinationAccount, + callback, + on_change_callback, }: Sep24PostParams): Promise { return this.flow({ assetCode, @@ -79,6 +85,8 @@ export class Sep24 { extraFields, destinationMemo, account: destinationAccount, + callback, + on_change_callback, type: FLOW_TYPE.DEPOSIT, }); } @@ -91,6 +99,10 @@ export class Sep24 { * @param {string} [params.lang] - The language for the request (defaults to the Anchor's language). * @param {ExtraFields} [params.extraFields] - Additional fields for the request. * @param {string} [params.withdrawalAccount] - The withdrawal account. + * @param {string} [params.callback] - The callback URL the anchor should POST to + * on a successfully completed interactive flow. + * @param {string} [params.on_change_callback] - The URL the anchor should POST to + * when the 'status' or 'kyc_verified' properties change. * @returns {Promise} The Sep24 response. * @throws {AssetNotSupportedError} If the asset is not supported for withdrawal. */ @@ -100,6 +112,8 @@ export class Sep24 { lang, extraFields, withdrawalAccount, + callback, + on_change_callback, }: Sep24PostParams): Promise { return this.flow({ assetCode, @@ -107,6 +121,8 @@ export class Sep24 { lang, extraFields, account: withdrawalAccount, + callback, + on_change_callback, type: FLOW_TYPE.WITHDRAW, }); } @@ -164,6 +180,12 @@ export class Sep24 { const interactiveResponse: Sep24PostResponse = resp.data; + if (params.callback) { + interactiveResponse.url = `${interactiveResponse.url}&callback=${params.callback}`; + } else if (params.on_change_callback) { + interactiveResponse.url = `${interactiveResponse.url}&on_change_callback=${params.on_change_callback}`; + } + return interactiveResponse; } catch (e) { throw new ServerRequestFailedError(e); diff --git a/src/walletSdk/Exceptions/index.ts b/src/walletSdk/Exceptions/index.ts index 3d1814b..4e0512b 100644 --- a/src/walletSdk/Exceptions/index.ts +++ b/src/walletSdk/Exceptions/index.ts @@ -270,3 +270,17 @@ export class Sep38PriceOnlyOneAmountError extends Error { Object.setPrototypeOf(this, Sep38PriceOnlyOneAmountError.prototype); } } + +export class ChallengeTxnIncorrectSequenceError extends Error { + constructor() { + super("Challenge transaction sequence number must be 0"); + Object.setPrototypeOf(this, ChallengeTxnIncorrectSequenceError.prototype); + } +} + +export class ChallengeTxnInvalidSignatureError extends Error { + constructor() { + super("Invalid signature for challenge transaction"); + Object.setPrototypeOf(this, ChallengeTxnInvalidSignatureError.prototype); + } +} diff --git a/src/walletSdk/Server/index.ts b/src/walletSdk/Server/index.ts new file mode 100644 index 0000000..8fa4d21 --- /dev/null +++ b/src/walletSdk/Server/index.ts @@ -0,0 +1,60 @@ +/** + * Code in the Server module is written to be used by server side + * applications. + */ + +import { + Transaction, + TransactionBuilder, + Keypair, + StellarToml, +} from "@stellar/stellar-sdk"; + +import { parseToml } from "../Utils"; +import { SignChallengeTxnParams, SignChallengeTxnResponse } from "../Types"; +import { + ChallengeTxnIncorrectSequenceError, + ChallengeTxnInvalidSignatureError, +} from "../Exceptions"; + +/** + * Helper method for signing a SEP-10 challenge transaction if valid. + * @param {SignChallengeTxnParams} params - The Authentication params. + * @param {AccountKeypair} params.accountKp - Keypair for the Stellar account signing the transaction. + * @param {string} [params.challengeTx] - The challenge transaction given by an anchor for authentication. + * @param {string} [params.networkPassphrase] - The network passphrase for the network authenticating on. + * @param {string} [params.anchorDomain] - Domain hosting stellar.toml file containing `SIGNING_KEY`. + * @returns {Promise} The signed transaction. + */ +export const signChallengeTransaction = async ({ + accountKp, + challengeTx, + networkPassphrase, + anchorDomain, +}: SignChallengeTxnParams): Promise => { + const tx = TransactionBuilder.fromXDR( + challengeTx, + networkPassphrase, + ) as Transaction; + + if (parseInt(tx.sequence) !== 0) { + throw new ChallengeTxnIncorrectSequenceError(); + } + + const tomlResp = await StellarToml.Resolver.resolve(anchorDomain); + const parsedToml = parseToml(tomlResp); + const anchorKp = Keypair.fromPublicKey(parsedToml.signingKey); + + const isValid = + tx.signatures.length && + anchorKp.verify(tx.hash(), tx.signatures[0].signature()); + if (!isValid) { + throw new ChallengeTxnInvalidSignatureError(); + } + + accountKp.sign(tx); + return { + transaction: tx.toXDR(), + networkPassphrase, + }; +}; diff --git a/src/walletSdk/Types/auth.ts b/src/walletSdk/Types/auth.ts index 0975b4d..ee494da 100644 --- a/src/walletSdk/Types/auth.ts +++ b/src/walletSdk/Types/auth.ts @@ -2,7 +2,7 @@ import { Transaction } from "@stellar/stellar-sdk"; import { decode } from "jws"; import { WalletSigner } from "../Auth/WalletSigner"; -import { AccountKeypair } from "../Horizon/Account"; +import { AccountKeypair, SigningKeypair } from "../Horizon/Account"; export type AuthenticateParams = { accountKp: AccountKeypair; @@ -79,3 +79,15 @@ export type SignWithDomainAccountParams = { export type HttpHeaders = { [key: string]: string; }; + +export type SignChallengeTxnParams = { + accountKp: SigningKeypair; + challengeTx: string; + networkPassphrase: string; + anchorDomain: string; +}; + +export type SignChallengeTxnResponse = { + transaction: XdrEncodedTransaction; + networkPassphrase: NetworkPassphrase; +}; diff --git a/src/walletSdk/Types/sep24.ts b/src/walletSdk/Types/sep24.ts index 4f81b25..936a36c 100644 --- a/src/walletSdk/Types/sep24.ts +++ b/src/walletSdk/Types/sep24.ts @@ -22,6 +22,8 @@ export type Sep24PostParams = { destinationAccount?: string; withdrawalAccount?: string; account?: string; + callback?: string; + on_change_callback?: string; }; export enum Sep24ResponseType { diff --git a/test/accountService.test.ts b/test/accountService.test.ts index 105acd6..50c12b5 100644 --- a/test/accountService.test.ts +++ b/test/accountService.test.ts @@ -29,50 +29,22 @@ describe("Horizon", () => { ); } - const testingAccountKp = SigningKeypair.fromSecret( - "SAXW2HC7JH5IJSIRFQ22JTMT6T3VONKGMYSIBLHNEJCV7AXLIGAXNESD", + // create test account + const testingAccountKp = accountService.createKeypair(); + await axios.get( + "https://friendbot.stellar.org/?addr=" + testingAccountKp.publicKey, ); - // make sure testing account exists - accountAddress = testingAccountKp.publicKey; - try { - await stellar.server.loadAccount(accountAddress); - } catch (e) { - const txBuilder1 = await stellar.transaction({ - sourceAddress: fundingAccountKp, - baseFee: 100, - }); - const createAccTx = txBuilder1.createAccount(testingAccountKp, 2).build(); - createAccTx.sign(fundingAccountKp.keypair); - - let failed = false; - try { - await stellar.submitTransaction(createAccTx); - await stellar.server.loadAccount(accountAddress); - } catch (e) { - failed = true; - } - - const txBuilder2 = await stellar.transaction({ - sourceAddress: testingAccountKp, - baseFee: 100, - }); - const asset = new IssuedAssetId( - "USDC", - "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - ); - const addUsdcTx = txBuilder2.addAssetSupport(asset).build(); - addUsdcTx.sign(testingAccountKp.keypair); + const txBuilder = await stellar.transaction({ + sourceAddress: testingAccountKp, + }); + const asset = new IssuedAssetId("USDC", fundingAccountKp.publicKey); + const addUsdcTx = txBuilder.addAssetSupport(asset).build(); + addUsdcTx.sign(testingAccountKp.keypair); - // make sure testing account has USDC trustline - try { - await stellar.submitTransaction(addUsdcTx); - } catch (e) { - failed = true; - } + await stellar.submitTransaction(addUsdcTx); - expect(failed).toBeFalsy(); - } + accountAddress = testingAccountKp.publicKey; }, 30000); it("should return stellar account details", async () => { diff --git a/test/server.test.ts b/test/server.test.ts new file mode 100644 index 0000000..423f399 --- /dev/null +++ b/test/server.test.ts @@ -0,0 +1,59 @@ +import { TransactionBuilder } from "@stellar/stellar-sdk"; +import { Wallet, Server } from "../src"; + +let wallet; +let account; +let accountKp; +const networkPassphrase = "Test SDF Network ; September 2015"; +describe("SEP-10 helpers", () => { + beforeEach(() => { + wallet = Wallet.TestNet(); + account = wallet.stellar().account(); + accountKp = account.createKeypair(); + }); + + it("should validate and sign challenge txn", async () => { + const validChallengeTx = + "AAAAAgAAAACpn2Fr7GAZ4XOcFvEz+xduBFDK1NDLQP875GtWWlJ0XQAAAMgAAAAAAAAAAAAAAAEAAAAAZa76AgAAAABlrv2GAAAAAAAAAAIAAAABAAAAALO9GbK9e+E+ul46lJyGjkzjlQnwqNryiqBsIR1vgMlAAAAACgAAABt0ZXN0YW5jaG9yLnN0ZWxsYXIub3JnIGF1dGgAAAAAAQAAAEBRT0ZDTE02OFQ0cVF4Um55TCtRdlBlVTdPeDJYNnhLdzdyenZTbzBBYUdqdUtIdGxQRkpHNTFKMndJazBwMXl2AAAAAQAAAACpn2Fr7GAZ4XOcFvEz+xduBFDK1NDLQP875GtWWlJ0XQAAAAoAAAAPd2ViX2F1dGhfZG9tYWluAAAAAAEAAAAWdGVzdGFuY2hvci5zdGVsbGFyLm9yZwAAAAAAAAAAAAFaUnRdAAAAQG6cMkt4YhwOzgizIimXRX8zTfFjAOItG7kSX14A454KlhGj9ocFhaRpj3tCc4fK45toFCBKRAdyFM7aQq331QI="; + + let isValid; + try { + const signedResp = await Server.signChallengeTransaction({ + accountKp, + challengeTx: validChallengeTx, + networkPassphrase, + anchorDomain: "testanchor.stellar.org", + }); + const signedTxn = TransactionBuilder.fromXDR( + signedResp.transaction, + networkPassphrase, + ); + expect(signedTxn.signatures.length).toBe(2); + expect(signedResp.networkPassphrase).toBe(networkPassphrase); + isValid = true; + } catch (e) { + isValid = false; + } + + expect(isValid).toBeTruthy(); + }); + + it("should invalidate bad challenge txn", async () => { + const invalidChallengeTx = + "AAAAAgAAAABQ5qHpn3ATIgt6yWrU4bhOdEszALPqLHb5V2pTRsYq0QAAAGQAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAACgAAABZ0ZXN0YW5jaG9yLnN0ZWxsYXIub3JnAAAAAAABAAAAQFFPRkNMTTY4VDRxUXhSbnlMK1F2UGVVN094Mlg2eEt3N3J6dlNvMEFhR2p1S0h0bFBGSkc1MUoyd0lrMHAxeXYAAAAAAAAAAA=="; + + let isValid; + try { + await Server.signChallengeTransaction({ + accountKp, + challengeTx: invalidChallengeTx, + networkPassphrase, + anchorDomain: "testanchor.stellar.org", + }); + isValid = true; + } catch (e) { + isValid = false; + } + expect(isValid).toBeFalsy(); + }); +}); diff --git a/test/wallet.test.ts b/test/wallet.test.ts index c2f3ea7..0233ac7 100644 --- a/test/wallet.test.ts +++ b/test/wallet.test.ts @@ -180,6 +180,28 @@ describe("Anchor", () => { expect(resp.id).toBeTruthy(); }); + it("should give url with a callback", async () => { + const assetCode = "SRT"; + const resp = await anchor.sep24().deposit({ + destinationAccount: accountKp.publicKey, + assetCode, + authToken, + callback: "mycallback.com", + }); + expect(resp.url.includes("callback=mycallback.com")).toBeTruthy(); + }); + + it("should give url with a on_change_callback", async () => { + const assetCode = "SRT"; + const resp = await anchor.sep24().deposit({ + destinationAccount: accountKp.publicKey, + assetCode, + authToken, + on_change_callback: "mycallback.com", + }); + expect(resp.url.includes("on_change_callback=mycallback.com")).toBeTruthy(); + }); + it("should give interactive deposit url without giving account and giving memo", async () => { const assetCode = "SRT"; const resp = await anchor.sep24().deposit({