PDO::ERRMODE_EXCEPTION,PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]); $d->exec('PRAGMA busy_timeout=5000'); $d->exec(file_get_contents(V2_ROOT . '/schema.sql')); return $d; } function sql(PDO $d, string $s, array $p = []): PDOStatement { $q = $d->prepare($s); $q->execute($p); return $q; } function units(string $v, int $dec): string { if (!preg_match('/^(0|[1-9][0-9]*)(\.[0-9]+)?$/D', $v) || $dec < 0 || $dec > 36) { throw new InvalidArgumentException('Invalid amount'); } $a = explode('.', $v, 2); $f = $a[1] ?? ''; if (strlen($f) > $dec) { throw new InvalidArgumentException('Too many decimals'); } return ltrim($a[0] . str_pad($f, $dec, '0'), '0') ?: '0'; } function vsave(PDO $d, string $n, array $r, bool $query = false): void { $l = sql($d, 'SELECT * FROM orders WHERE sh_order=?', [$n])->fetch(); if (!$l) { throw new InvalidArgumentException('Unknown local order'); } $v = $r['data']; if ($query) { if ($v['out_order_sn'] !== $n || count($v['orders']) !== 1) { throw new InvalidArgumentException('Ambiguous query'); } $v = $v['orders'][0]; } $c = $v['crypto']; if ((string)($c['callback_version'] ?? '') !== '2') { throw new InvalidArgumentException('Order did not enable v2 callbacks'); } $pt = (string)($query ? $c['ptype'] : $v['ptype']); if ($pt !== $l['ptype'] || units((string)$v['money'], 2) !== units($l['money'], 2) || units($l['money'], (int)$c['decimals']) !== (string)$c['expected_units']) { throw new InvalidArgumentException('Response mismatch'); } $e = []; foreach (['network','is_testnet','asset','address','decimals'] as $k) { $e[$k] = (string)$c[$k]; } $e += ['mch_id' => (string)$c['merchant_id'],'pt_order' => $v['order_sn'],'sh_order' => $n,'money' => (string)$v['money'],'pay_money' => (string)($v['pay_money'] ?? $v['money']),'ptype' => $pt,'expected_amount_raw' => (string)$c['expected_units']]; if ($l['expected'] && json_decode($l['expected'], true) != $e) { throw new InvalidArgumentException('Saved snapshot cannot change'); } sql($d, 'UPDATE orders SET expected=? WHERE sh_order=?', [json_encode($e, JSON_THROW_ON_ERROR),$n]); } function vapply(PDO $d, array $p, string $key): void { $fields = explode(' ', V2_FIELDS); if (array_diff($fields, array_keys($p)) || array_diff(array_keys($p), $fields)) { throw new InvalidArgumentException('Invalid fields'); } foreach ($p as $v) { if (!is_string($v)) { throw new InvalidArgumentException('Original strings required'); } } if (!preg_match('/^[a-f0-9]{32}$/D', $p['sign']) || !hash_equals(merchantSign($p, $key), $p['sign'])) { throw new InvalidArgumentException('Invalid signature'); } if ($p['callback_version'] !== '2' || !preg_match('/^cn_[a-f0-9]{32}$/D', $p['event_id']) || !preg_match('/^[1-9][0-9]{0,9}$/D', $p['event_seq']) || (int)$p['event_seq'] > 2147483647) { throw new InvalidArgumentException('Invalid event'); } $status = $p['status']; $seq = (int)$p['event_seq']; if (!in_array($status, ['expired','confirming','confirmed'], true) || !in_array($p['payment_detected'], ['0','1'], true) || $p['confirmed'] !== ($status === 'confirmed' ? '1' : '0') || ($status !== 'expired' && $p['payment_detected'] !== '1')) { throw new InvalidArgumentException('Invalid phase'); } if ($p['payment_detected'] === '1') { if ($p['amount_raw'] !== $p['expected_amount_raw'] || $p['tx_hash'] === '' || $p['event_index'] === '') { throw new InvalidArgumentException('Transfer mismatch'); } } elseif ($p['amount_raw'] !== '' || $p['tx_hash'] !== '' || $p['event_index'] !== '') { throw new InvalidArgumentException('Unexpected transfer'); } $stream = $p['reason'] === 'test_callback' ? 'test' : 'payment'; $signed = $p; unset($signed['sign']); ksort($signed, SORT_STRING); $parts = []; foreach ($signed as $k => $v) { $parts[] = $k . '=' . $v; } $digest = hash('sha256', implode('&', $parts)); $d->exec('BEGIN IMMEDIATE');try { $l = sql($d, 'SELECT expected FROM orders WHERE sh_order=?', [$p['sh_order']])->fetch(); if (!$l || !$l['expected']) { throw new InvalidArgumentException('Unknown order'); } $e = json_decode($l['expected'], true); foreach (explode(' ', V2_IDENTITY) as $k) { if (($e[$k] ?? null) !== $p[$k]) { throw new InvalidArgumentException('Order mismatch'); } } $old = sql($d, 'SELECT digest FROM events WHERE event_id=?', [$p['event_id']])->fetch(); if ($old) { if ($old['digest'] !== $digest) { throw new InvalidArgumentException('Conflicting replay'); } $d->exec('COMMIT'); return; } if (sql($d, 'SELECT 1 FROM events WHERE sh_order=? AND stream=? AND seq=?', [$p['sh_order'],$stream,$seq])->fetch()) { throw new InvalidArgumentException('Conflicting sequence'); } sql($d, 'INSERT OR IGNORE INTO states(sh_order,stream) VALUES (?,?)', [$p['sh_order'],$stream]); $prev = sql($d, 'SELECT seq,status FROM states WHERE sh_order=? AND stream=?', [$p['sh_order'],$stream])->fetch(); if ($seq > $prev['seq'] && $prev['status'] === 'confirmed' && $status !== 'confirmed') { throw new InvalidArgumentException('State regression'); } sql($d, 'INSERT INTO events VALUES (?,?,?,?,?)', [$p['event_id'],$p['sh_order'],$stream,$seq,$digest]); if ($seq > $prev['seq']) { sql($d, 'UPDATE states SET seq=?,status=? WHERE sh_order=? AND stream=?', [$seq,$status,$p['sh_order'],$stream]); if ($status === 'confirmed') { sql($d, 'INSERT OR IGNORE INTO outbox VALUES (?,?,?)', [$p['sh_order'],$stream,$p['event_id']]); } } // Put DB business writes here; external delivery consumes the outbox after commit. $d->exec('COMMIT'); } catch (Throwable $e) { $d->exec('ROLLBACK'); throw $e; } } function vinspect(PDO $d): array { $r = []; foreach (['states','events','outbox'] as $t) { $r[$t] = sql($d, 'SELECT * FROM ' . $t)->fetchAll(); } return $r; } function vselftest(): void { $f = json_decode(file_get_contents(V2_ROOT . '/fixtures.json'), true, 512, JSON_THROW_ON_ERROR); $dir = sys_get_temp_dir() . '/crypto-v2-' . bin2hex(random_bytes(8)); $d = vdb(['PAY_STATE_DIR' => $dir]); try { foreach ($f['orders'] as $e) { sql($d, 'INSERT INTO orders VALUES (?,?,?,?)', [$e['sh_order'],$e['money'],$e['ptype'],json_encode($e)]); } foreach ($f['cases'] as $case) { $before = vinspect($d); try { vapply($d, $case['payload'], $f['key']); $ok = true; } catch (InvalidArgumentException $e) { $ok = false; } if ($ok !== $case['accept'] || (!$ok && vinspect($d) != $before)) { throw new RuntimeException($case['name']); } } if (vinspect($d) != $f['final']) { throw new RuntimeException('Persistence mismatch'); } foreach (['2911', '2912', '2913'] as $pt) { $created = json_decode(file_get_contents(V2_ROOT . '/responses/' . $pt . '-create.json'), true, 512, JSON_THROW_ON_ERROR); $queried = json_decode(file_get_contents(V2_ROOT . '/responses/' . $pt . '-query.json'), true, 512, JSON_THROW_ON_ERROR); $number = $queried['data']['out_order_sn']; sql($d, 'INSERT INTO orders(sh_order,money,ptype) VALUES (?,?,?)', [$number, '1.00', $pt]); vsave($d, $number, $created); vsave($d, $number, $queried, true); } echo 'PASS ' . count($f['cases']) . " callback cases, rollback, state and outbox\n"; } finally { $d = null; foreach (glob($dir . '/*') as $p) { unlink($p); } rmdir($dir); } } function vmain(array $argv): void { $c = vconfig(); $action = $argv[1] ?? 'help'; if ($action === 'selftest') { vselftest(); return; } $d = vdb($c); if ($action === 'inspect') { $r = vinspect($d); } elseif ($action === 'balance') { $r = merchantRequest('querybalance', [], $c); } elseif (in_array($action, ['create','demo'], true)) { if (str_contains($c['PAY_NOTIFY_URL'], 'YOUR-MERCHANT-HOST')) { throw new InvalidArgumentException('Set PAY_NOTIFY_URL to your HTTPS receiver'); } if (!in_array($c['PAY_PTYPE'], ['2911','2912','2913','2901','2902','2903'], true)) { throw new InvalidArgumentException('Invalid channel'); }units($c['PAY_MONEY'], 2); $n = $argv[2] ?? 'CV2' . bin2hex(random_bytes(13)); sql($d, 'INSERT INTO orders(sh_order,money,ptype) VALUES (?,?,?)', [$n,$c['PAY_MONEY'],$c['PAY_PTYPE']]); echo 'Saved merchant order: ' . $n . "\n"; $r = merchantRequest('index', ['ptype' => $c['PAY_PTYPE'],'money' => $c['PAY_MONEY'],'order_sn' => $n,'goods_desc' => '','client_ip' => '','format' => 'json','notify_url' => $c['PAY_NOTIFY_URL'],'crypto_callback_version' => '2'], $c); vsave($d, $n, $r); if ($action === 'demo') { $r['query_result'] = merchantRequest('query', ['out_order_sn' => $n], $c); } } elseif ($action === 'query') { $n = $argv[2]; $r = merchantRequest('query', ['out_order_sn' => $n], $c); vsave($d, $n, $r, true); } else { echo "selftest | balance | demo | create [ORDER] | query ORDER | inspect; receiver: php -S 127.0.0.1:8082 -t php\n"; return; } echo json_encode($r, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n"; } if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) { try { vmain($argv); } catch (Throwable $e) { fwrite(STDERR, 'ERROR: ' . $e->getMessage() . "; query saved order before retrying creation.\n"); exit(1); } }